commit c251f174edc08e071b989b0f32ae3c14ad227b33 Author: Dongho Kim Date: Mon Dec 8 16:16:23 2025 +0100 update diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..6cf7a5e --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,27 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.148.1/containers/go/.devcontainer/base.Dockerfile + +# [Choice] Go version: 1, 1.15, 1.14 +ARG VARIANT="1" +FROM mcr.microsoft.com/vscode/devcontainers/go:${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 +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && apt-get -y install --no-install-recommends ffmpeg + +# Install TagLib from cross-taglib releases +ARG CROSS_TAGLIB_VERSION="2.1.1-1" +ARG TARGETARCH +RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \ + && wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \ + && tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \ + && mv /usr/include/taglib/* /usr/include/ \ + && rmdir /usr/include/taglib \ + && rm /tmp/cross-taglib.tar.gz /usr/provenance.json + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..0519f25 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,64 @@ +{ + "name": "Go", + "build": { + "dockerfile": "Dockerfile", + "args": { + // Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14 + "VARIANT": "1.25", + // Options + "INSTALL_NODE": "true", + "NODE_VERSION": "v24", + "CROSS_TAGLIB_VERSION": "2.1.1-1" + } + }, + "workspaceMount": "", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined", + "--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" + ] + } + }, + // Use 'forwardPorts' to make a list of ports inside the container available locally. + "forwardPorts": [ + 4533, + 4633 + ], + // 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 new file mode 100644 index 0000000..596aa29 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,18 @@ +.DS_Store +ui/node_modules +ui/build +!ui/build/.gitkeep +Dockerfile +docker-compose*.yml +data +*.db +testDB +navidrome +navidrome.toml +tmp +!tmp/taglib +dist +binaries +cache +music +!Dockerfile \ No newline at end of file diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..14a3252 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# Upgrade Prettier to 2.0.4. Reformatted all JS files +b3f70538a9138bc279a451f4f358605097210d41 +# Move project to Navidrome GitHub organization +6ee45a9ccc5e7ea4290c89030e67c99c0514bd25 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..534cfbd --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: 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 +otechie: # Replace with a single Otechie username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..43c4b3e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,103 @@ +name: Bug Report +description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered. +title: "[Bug]: " +labels: ["bug", "triage"] +#assignees: +# - deluan +body: + - type: markdown + attributes: + value: | + ### Thanks for taking the time to fill out this bug report! + - type: checkboxes + id: requirements + attributes: + label: "I confirm that:" + options: + - label: I have searched the existing [open AND closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) to see if an issue already exists for the bug I've encountered + required: true + - label: I'm using the latest version (your issue may have been fixed already) + required: false + - type: input + id: version + attributes: + label: Version + description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already). + validations: + required: true + - type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this scenario... + 2. With this config... + 3. Click (or Execute) '...' + 4. See error... + validations: + required: false + - type: textarea + id: env + attributes: + label: Environment + description: | + examples: + - **OS**: Ubuntu 20.04 + - **Browser**: Chrome 110.0.5481.177 on Windows 11 + - **Client**: DSub 5.5.1 + value: | + - OS: + - Browser: + - Client: + render: markdown + - type: dropdown + id: distribution + attributes: + label: How Navidrome is installed? + multiple: false + options: + - Docker + - Binary (from downloads page) + - Package + - Built from sources + validations: + required: true + - type: textarea + id: config + attributes: + label: Configuration + description: Please copy and paste your `navidrome.toml` (and/or `docker-compose.yml`) configuration. This will be automatically formatted into code, so no need for backticks. + render: toml + - type: textarea + id: logs + attributes: + label: Relevant log output + description: Please copy and paste any relevant log output (change your `LogLevel` (`ND_LOGLEVEL`) to debug). This will be automatically formatted into code, so no need for backticks. ([Where I can find the logs?](https://www.navidrome.org/docs/faq/#where-are-the-logs)) + render: shell + - type: textarea + attributes: + label: Anything else? + description: | + Links? References? Anything that will give us more context about the issue you are encountering! + + Tip: You can attach screenshots by clicking this area to highlight it and then dragging files in. + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md). + options: + - label: I agree to follow Navidrome's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e2eb940 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Ideas for new features + url: https://github.com/navidrome/navidrome/discussions/categories/ideas + about: This is the place to share and discuss new ideas and potentially new features. + - name: Support requests + url: https://github.com/navidrome/navidrome/discussions/categories/q-a + about: This is the place to ask questions. diff --git a/.github/actions/download-taglib/action.yml b/.github/actions/download-taglib/action.yml new file mode 100644 index 0000000..ea6de87 --- /dev/null +++ b/.github/actions/download-taglib/action.yml @@ -0,0 +1,23 @@ +name: 'Download TagLib' +description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH' +inputs: + version: + description: 'Version of TagLib to download' + required: true + platform: + description: 'Platform to download TagLib for' + default: 'linux-amd64' +runs: + using: 'composite' + steps: + - name: Download TagLib + shell: bash + run: | + mkdir -p /tmp/taglib + cd /tmp + FILE=taglib-${{ inputs.platform }}.tar.gz + wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE} + tar -xzf ${FILE} -C taglib + PKG_CONFIG_PREFIX=/tmp/taglib + echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV + echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml new file mode 100644 index 0000000..760a052 --- /dev/null +++ b/.github/actions/prepare-docker/action.yml @@ -0,0 +1,84 @@ +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@v3 + with: + username: ${{ inputs.hub_username }} + password: ${{ inputs.hub_password }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ inputs.github_token }} + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata for Docker image + id: meta + uses: docker/metadata-action@v5 + with: + 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 new file mode 100644 index 0000000..327ab0d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/ui" + schedule: + interval: weekly + open-pull-requests-limit: 10 +- package-ecosystem: gomod + directory: "/" + 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 new file mode 100644 index 0000000..10431e9 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ +### 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/screenshots/ss-desktop-player.png b/.github/screenshots/ss-desktop-player.png new file mode 100644 index 0000000..8536856 Binary files /dev/null and b/.github/screenshots/ss-desktop-player.png differ diff --git a/.github/screenshots/ss-mobile-album-view.png b/.github/screenshots/ss-mobile-album-view.png new file mode 100644 index 0000000..9721e5e Binary files /dev/null and b/.github/screenshots/ss-mobile-album-view.png differ diff --git a/.github/screenshots/ss-mobile-login.png b/.github/screenshots/ss-mobile-login.png new file mode 100644 index 0000000..da39cca Binary files /dev/null and b/.github/screenshots/ss-mobile-login.png differ diff --git a/.github/screenshots/ss-mobile-player.png b/.github/screenshots/ss-mobile-player.png new file mode 100644 index 0000000..ee3a4f5 Binary files /dev/null and b/.github/screenshots/ss-mobile-player.png differ diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml new file mode 100644 index 0000000..38b7b8a --- /dev/null +++ b/.github/workflows/download-link-on-pr.yml @@ -0,0 +1,54 @@ +name: Add download link to PR +on: + workflow_run: + workflows: ['Pipeline: Test, Lint, Build'] + types: [completed] +jobs: + pr_comment: + if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + steps: + - 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 + script: | + const {owner, repo} = context.repo; + const run_id = ${{github.event.workflow_run.id}}; + const pull_head_sha = '${{github.event.workflow_run.head_sha}}'; + const pull_user_id = ${{github.event.sender.id}}; + + const issue_number = await (async () => { + const pulls = await github.pulls.list({owner, repo}); + for await (const {data} of github.paginate.iterator(pulls)) { + for (const pull of data) { + if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) { + return pull.number; + } + } + } + })(); + if (issue_number) { + core.info(`Using pull request ${issue_number}`); + } else { + return core.error(`No matching pull request found`); + } + + const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id}); + if (!artifacts.length) { + return core.error(`No artifacts found`); + } + 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.issues.listComments({repo, owner, issue_number}); + const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); + if (existing_comment) { + core.info(`Updating comment ${existing_comment.id}`); + await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); + } else { + core.info(`Creating a comment`); + await github.issues.createComment({repo, owner, issue_number, body}); + } diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..43c39f4 --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,467 @@ +name: "Pipeline: Test, Lint, Build" +on: + push: + branches: + - master + tags: + - "v*" + pull_request: + branches: + - master + +concurrency: + group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }} + cancel-in-progress: true + +env: + CROSS_TAGLIB_VERSION: "2.1.1-1" + 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@v6 + 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: $(git describe --tags `git rev-list --tags --max-count=1`)" + 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 tag --sort=-committerdate | head -n 1) + 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@v6 + + - name: Download TagLib + uses: ./.github/actions/download-taglib + with: + version: ${{ env.CROSS_TAGLIB_VERSION }} + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + version: latest + problem-matchers: true + 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$'` + - 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' + exit 1 + fi + + go: + name: Test Go code + runs-on: ubuntu-latest + steps: + - name: Check out code into the Go module directory + uses: actions/checkout@v6 + + - name: Download TagLib + uses: ./.github/actions/download-taglib + with: + version: ${{ env.CROSS_TAGLIB_VERSION }} + + - name: Download dependencies + run: go mod download + + - name: Test + run: | + pkg-config --define-prefix --cflags --libs taglib # for debugging + go test -shuffle=on -tags netgo -race ./... -v + + js: + name: Test JS code + runs-on: ubuntu-latest + env: + NODE_OPTIONS: "--max_old_space_size=4096" + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: "npm" + cache-dependency-path: "**/package-lock.json" + + - name: npm install dependencies + run: | + cd ui + npm ci + + - name: npm lint + run: | + cd ui + npm run check-formatting && npm run lint + + - name: npm test + run: | + cd ui + npm test + + - name: npm build + run: | + cd ui + npm run build + + i18n-lint: + name: Lint i18n files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - 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-lint, i18n-lint, git-version, check-push-enabled] + strategy: + matrix: + platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] + 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@v6 + + - 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@v6 + 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 }} + CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} + + - name: Upload Binaries + uses: actions/upload-artifact@v5 + 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@v6 + with: + context: . + file: Dockerfile + platforms: ${{ matrix.platform }} + labels: ${{ steps.docker.outputs.labels }} + build-args: | + GIT_SHA=${{ env.GIT_SHA }} + GIT_TAG=${{ env.GIT_TAG }} + CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }} + outputs: | + type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }} + type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true + + - 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@v5 + 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@v6 + + - name: Download digests + uses: actions/download-artifact@v6 + 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@v6 + + - name: Download digests + uses: actions/download-artifact@v6 + 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@v3 + 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/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@v6 + + - uses: actions/download-artifact@v6 + 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@v5 + 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@v6 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/download-artifact@v6 + with: + path: ./binaries + pattern: navidrome-* + merge-multiple: true + + - 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@v6 + with: + version: '~> v2' + args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}" + 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@v5 + with: + name: packages + path: dist/navidrome_0* + + - 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 + + upload-packages: + name: Upload Linux PKG + runs-on: ubuntu-latest + needs: [release] + strategy: + matrix: + item: ${{ fromJson(needs.release.outputs.package_list) }} + steps: + - name: Download all-packages artifact + uses: actions/download-artifact@v6 + with: + name: packages + path: ./dist + + - name: Upload all-packages artifact + uses: actions/upload-artifact@v5 + with: + name: navidrome_linux_${{ matrix.item }} + path: dist/navidrome_0*_linux_${{ matrix.item }} + +# 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 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..c8bf3ae --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,56 @@ +name: 'Close stale issues and PRs' +on: + workflow_dispatch: + schedule: + - cron: '30 1 * * *' +permissions: + contents: read +jobs: + stale: + permissions: + issues: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: dessant/lock-threads@v5 + with: + process-only: 'issues, prs' + issue-inactive-days: 120 + pr-inactive-days: 120 + log-output: true + add-issue-labels: 'frozen-due-to-age' + add-pr-labels: 'frozen-due-to-age' + issue-comment: > + This issue has been automatically locked since there + has not been any recent activity after it was closed. + Please open a new issue for related bugs. + pr-comment: > + This pull request has been automatically locked since there + has not been any recent activity after it was closed. + Please open a new issue for related bugs. + - uses: actions/stale@v9 + with: + operations-per-run: 999 + days-before-issue-stale: 180 + days-before-pr-stale: 180 + days-before-issue-close: 30 + days-before-pr-close: 30 + stale-issue-message: > + This issue has been automatically marked as stale because it has not had + recent activity. The resources of the Navidrome team are limited, and so we are asking for your help. + + If this is a **bug** and you can still reproduce this error on the master branch, please reply with all of the information you have about it in order to keep the issue open. + + If this is a **feature request**, and you feel that it is still relevant and valuable, please tell us why. + + This issue will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions. + stale-pr-message: This PR has been automatically marked as stale because it has not had + recent activity. The resources of the Navidrome team are limited, and so we are asking for your help. + + Please check https://github.com/navidrome/navidrome/blob/master/CONTRIBUTING.md#pull-requests and verify that this code contribution fits with the description. If yes, tell it in a comment. + + This PR will automatically be closed in the near future if no further activity occurs. Thank you for all your contributions. + stale-issue-label: 'stale' + exempt-issue-labels: 'keep,security' + stale-pr-label: 'stale' + exempt-pr-labels: 'keep,security' diff --git a/.github/workflows/update-translations.sh b/.github/workflows/update-translations.sh new file mode 100755 index 0000000..23d0ef2 --- /dev/null +++ b/.github/workflows/update-translations.sh @@ -0,0 +1,93 @@ +#!/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 new file mode 100644 index 0000000..cc120cb --- /dev/null +++ b/.github/workflows/update-translations.yml @@ -0,0 +1,33 @@ +name: POEditor import +on: + workflow_dispatch: + schedule: + - cron: '0 10 * * *' +jobs: + update-translations: + runs-on: ubuntu-latest + if: ${{ github.repository_owner == 'navidrome' }} + steps: + - uses: actions/checkout@v6 + - 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 + - name: Show changes, if any + run: | + git status --porcelain + git diff + - name: Create Pull Request + uses: peter-evans/create-pull-request@v7 + 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" + branch: update-translations diff --git a/.github/workflows/validate-translations.sh b/.github/workflows/validate-translations.sh new file mode 100755 index 0000000..a6b346e --- /dev/null +++ b/.github/workflows/validate-translations.sh @@ -0,0 +1,236 @@ +#!/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 new file mode 100644 index 0000000..03852f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +.DS_Store +.idea +.vscode +.envrc +/navidrome +/iTunes*.xml +/tmp +/bin +data/* +vendor/*/ +wiki +TODO.md +var +navidrome.toml +!release/linux/navidrome.toml +master.zip +testDB +cache/* +*.swp +dist +music +*.db* +.gitinfo +docker-compose.yml +!contrib/docker-compose.yml +binaries +navidrome-* +AGENTS.md +.github/prompts +.github/instructions +.github/git-commit-instructions.md +*.exe +*.test +*.wasm +openspec/ \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..996dafc --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,58 @@ +version: "2" +run: + build-tags: + - netgo +linters: + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - copyloopvar + - dogsled + - durationcheck + - errorlint + - gocritic + - gocyclo + - goprintffuncname + - gosec + - misspell + - nakedret + - nilerr + - rowserrcheck + - unconvert + - whitespace + disable: + - staticcheck + settings: + gocritic: + disable-all: true + enabled-checks: + - deprecatedComment + gosec: + excludes: + - G501 + - G401 + - G505 + - G115 + govet: + enable: + - nilness + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..54c6511 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v24 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..543c523 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,129 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +navidrome@navidrome.org. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..f2631f5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Navidrome Contribution Guide + +Navidrome is a streaming service which allows you to enjoy your music collection from anywhere. We'd welcome you to contribute to our open source project and make Navidrome even better. There are some basic guidelines which you need to follow if you like to contribute to Navidrome. + +- [Asking Support Questions](#asking-support-questions) +- [Code of Conduct](#code-of-conduct) +- [Issues](#issues) +- [Pull Requests](#pull-requests) + + +## Asking Support Questions +We have an active [discussion forum](https://github.com/navidrome/navidrome/discussions) where users and developers can ask questions. Please don't use the GitHub issue tracker to ask questions. + +## Code of Conduct +Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md). + +## Issues +Found any issue or bug in our codebase? Have a great idea you want to propose or discuss with +the developers? You can help by submitting an [issue](https://github.com/navidrome/navidrome/issues/new/choose) +to the GitHub repository. + +**Before opening a new issue, please check if the issue has not been already made by searching +the [issues](https://github.com/navidrome/navidrome/issues)** + +## Pull requests +Before submitting a pull request, ensure that you go through the following: +- Open a corresponding issue for the Pull Request, if not existing. The issue can be opened following [these guidelines](#issues) +- Ensure that there is no open or closed Pull Request corresponding to your submission to avoid duplication of effort. +- Setup the [development environment](https://www.navidrome.org/docs/developers/dev-environment/) +- Create a new branch on your forked repo and make the changes in it. Naming conventions for branch are: `/`. Example: +``` + git checkout -b adding-docs/834 master +``` +- The commits should follow a [specific convention](#commit-conventions) +- Ensure that a DCO sign-off for commits is provided via `--signoff` option of git commit +- Provide a link to the issue that will be closed via your Pull request. + +### Commit Conventions +Each commit message must adhere to the following format: +``` +(scope): - + +[optional body] +``` +This improves the readability of the messages + +#### Type +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 + +If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section + +#### Scope +The file or folder where the changes are made. If there are more than one, you can mention any + +#### Description +A short description of the issue + +#### Issue number +The issue fixed by this Pull Request. + +The body is optional. It may contain short description of changes made. + +Following all the guidelines an ideal commit will look like: +``` + git commit --signoff -m "feat(themes): New-theme - #834" +``` + +After committing, push your commits to your forked branch and create a Pull Request from there. +The Pull Request Title can be the same as `(scope): - ` +A demo layout of how the Pull request body can look: +``` +Closes + +Description (What does the pull request do) + +Changes (What changes were made ) + +Screenshots or Videos + +Related Issues and Pull Requests(if any) + +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6568ce9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,146 @@ +FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross + +######################################################################################################################## +### Build xx (original image: tonistiigi/xx) +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS xx-build + +# v1.5.0 +ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a + +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/ + +######################################################################################################################## +### Get TagLib +FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.19 AS taglib-build +ARG TARGETPLATFORM +ARG CROSS_TAGLIB_VERSION=2.1.1-1 +ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/ + +# wget in busybox can't follow redirects +RUN < + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1de789c --- /dev/null +++ b/Makefile @@ -0,0 +1,297 @@ +GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ') +NODE_VERSION=$(shell cat .nvmrc) + +ifneq ("$(wildcard .git/HEAD)","") +GIT_SHA=$(shell git rev-parse --short HEAD) +GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)-SNAPSHOT +else +GIT_SHA=source_archive +GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT +endif + +SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386 +IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//') +PLATFORMS ?= $(SUPPORTED_PLATFORMS) +DOCKER_TAG ?= deluan/navidrome:develop + +# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib +CROSS_TAGLIB_VERSION ?= 2.1.1-1 +GOLANGCI_LINT_VERSION ?= v2.6.2 + +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 + @echo Downloading Node dependencies... + @(cd ./ui && npm ci) +.PHONY: setup + +dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend + ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start +.PHONY: dev + +server: check_go_env buildjs ##@Development Start the backend in development mode + @ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -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=netgo -notify ./... +.PHONY: watch + +PKG ?= ./... +test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server + go test -tags netgo $(PKG) +.PHONY: test + +testall: test-race test-i18n test-js ##@Development Run Go and JS tests +.PHONY: testall + +test-race: ##@Development Run Go tests with race detector + go test -tags netgo -race -shuffle=on $(PKG) +.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=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \ + CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ + 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 + +lint: install-golangci-lint ##@Development Lint Go code + PATH=$$PATH:./bin golangci-lint run -v --timeout 5m +.PHONY: lint + +lintall: lint ##@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=netgo ./... +.PHONY: wire + +snapshots: ##@Development Update (GoLang) Snapshot tests + UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/... +.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 +.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} +.PHONY: migration + +setup-dev: setup +.PHONY: setup-dev + +setup-git: ##@Development Setup Git hooks (pre-commit and pre-push) + @echo Setting up git hooks + @mkdir -p .git/hooks + @(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=netgo +.PHONY: build + +buildall: deprecated build +.PHONY: buildall + +debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on) + go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo +.PHONY: debug-build + +buildjs: check_node_env ui/build/index.html ##@Build Build only frontend +.PHONY: buildjs + +docker-buildjs: ##@Build Build only frontend using Docker + docker build --output "./ui" --target ui-bundle . +.PHONY: docker-buildjs + +ui/build/index.html: $(UI_SRC_FILES) + @(cd ./ui && npm run build) + +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} \ + --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \ + --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} \ + --build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \ + --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 + +run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag= + @if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag="; exit 1; fi + @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: run-docker + +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 + +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; \ + for file in *.zip; do unzip -n $${file}; done ) + @echo "Done. Remember to set your MusicFolder to ./music" +.PHONY: get-music + + +########################################## +#### 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 + @if [ -n "`git status -s`" ]; then echo "\n\nThere are pending changes. Please commit or stash first"; exit 1; fi + make pre-push + git tag v${V} + git push origin v${V} --no-verify +.PHONY: release + +download-deps: + @echo Downloading Go dependencies... + @go mod download + @go mod tidy # To revert any changes made by the `go mod download` command +.PHONY: download-deps + +check_env: check_go_env check_node_env +.PHONY: check_env + +check_go_env: + @(hash go) || (echo "\nERROR: GO environment not setup properly!\n"; exit 1) + @current_go_version=`go version | cut -d ' ' -f 3 | cut -c3-` && \ + echo "$(GO_VERSION) $$current_go_version" | \ + tr ' ' '\n' | sort -V | tail -1 | \ + grep -q "^$${current_go_version}$$" || \ + (echo "\nERROR: Please upgrade your GO version\nThis project requires at least the version $(GO_VERSION)"; exit 1) +.PHONY: check_go_env + +check_node_env: + @(hash node) || (echo "\nERROR: Node environment not setup properly!\n"; exit 1) + @current_node_version=`node --version` && \ + echo "$(NODE_VERSION) $$current_node_version" | \ + tr ' ' '\n' | sort -V | tail -1 | \ + grep -q "^$${current_node_version}$$" || \ + (echo "\nERROR: Please check your Node version. Should be at least $(NODE_VERSION)\n"; exit 1) +.PHONY: 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 + +# Generate Go code from plugins/api/api.proto +plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files + go generate ./plugins/... +.PHONY: plugin-gen + +plugin-examples: check_go_env ##@Development Build all example plugins + $(MAKE) -C plugins/examples clean all +.PHONY: plugin-examples + +plugin-clean: check_go_env ##@Development Clean all plugins + $(MAKE) -C plugins/examples clean + $(MAKE) -C plugins/testdata clean +.PHONY: plugin-clean + +plugin-tests: check_go_env ##@Development Build all test plugins + $(MAKE) -C plugins/testdata clean all +.PHONY: plugin-tests + +.DEFAULT_GOAL := help + +HELP_FUN = \ + %help; while(<>){push@{$$help{$$2//'options'}},[$$1,$$3] \ + if/^([\w-_]+)\s*:.*\#\#(?:@(\w+))?\s(.*)$$/}; \ + print"$$_:\n", map" $$_->[0]".(" "x(20-length($$_->[0])))."$$_->[1]\n",\ + @{$$help{$$_}},"\n" for sort keys %help; \ + +help: ##@Miscellaneous Show this help + @echo "Usage: make [target] ...\n" + @perl -e '$(HELP_FUN)' $(MAKEFILE_LIST) diff --git a/Procfile.dev b/Procfile.dev new file mode 100644 index 0000000..0c187e8 --- /dev/null +++ b/Procfile.dev @@ -0,0 +1,2 @@ +JS: sh -c "cd ./ui && npm start" +GO: go tool reflex -d none -c reflex.conf diff --git a/README.md b/README.md new file mode 100644 index 0000000..718da24 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +Navidrome logo + +# Navidrome Music Server  [![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Tired%20of%20paying%20for%20music%20subscriptions%2C%20and%20not%20finding%20what%20you%20really%20like%3F%20Roll%20your%20own%20streaming%20service%21&url=https://navidrome.org&via=navidrome) + +[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases) +[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master) +[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest) +[![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome) +[![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! + +This is a modified version of the [original Navidrome](https://github.com/navidrome/navidrome), enhanced with Meilisearch support. + + +**Note**: The `master` branch may be in an unstable or even broken state during development. +Please use [releases](https://github.com/navidrome/navidrome/releases) instead of +the `master` branch in order to get a stable set of binaries. + +## [Check out our Live Demo!](https://www.navidrome.org/demo/) + +__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome, +please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our +[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way +([ui/backend dev](https://www.navidrome.org/docs/developers/), +[translations](https://www.navidrome.org/docs/developers/translations/), +[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our +[Discord server](https://discord.gg/xh7j7yF). + +## Installation + +See instructions on the [project's website](https://www.navidrome.org/docs/installation/) + +## Cloud Hosting + +[PikaPods](https://www.pikapods.com) has partnered with us to offer you an +[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods). +A share of the revenue helps fund the development of Navidrome at no additional cost for you. + +[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome) + +## Features + + - Handles very **large music collections** + - Streams virtually **any audio format** available + - Reads and uses all your beautifully curated **metadata** + - Great support for **compilations** (Various Artists albums) and **box sets** (multi-disc albums) + - **Multi-user**, each user has their own play counts, playlists, favourites, etc... + - Very **low resource usage** + - **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 + - **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** + - **Meilisearch Integration** for high-performance full-text search (optional) + - 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: + +- [Overview](https://www.navidrome.org/docs/overview/) +- [Installation](https://www.navidrome.org/docs/installation/) + - [Docker](https://www.navidrome.org/docs/installation/docker/) + - [Binaries](https://www.navidrome.org/docs/installation/pre-built-binaries/) + - [Build from source](https://www.navidrome.org/docs/installation/build-from-source/) +- [Development](https://www.navidrome.org/docs/developers/) +- [Subsonic API Compatibility](https://www.navidrome.org/docs/developers/subsonic-api/) + +## Screenshots + +

+ + + + +

diff --git a/adapters/taglib/end_to_end_test.go b/adapters/taglib/end_to_end_test.go new file mode 100644 index 0000000..e4d94bb --- /dev/null +++ b/adapters/taglib/end_to_end_test.go @@ -0,0 +1,278 @@ +package taglib + +import ( + "io/fs" + "os" + "time" + + "github.com/djherbis/times" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type testFileInfo struct { + fs.FileInfo +} + +func (t testFileInfo) BirthTime() time.Time { + if ts := times.Get(t.FileInfo); ts.HasBirthTime() { + return ts.BirthTime() + } + return t.FileInfo.ModTime() +} + +var _ = Describe("Extractor", func() { + toP := func(name, sortName, mbid string) model.Participant { + return model.Participant{ + Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid}, + } + } + + roles := []struct { + model.Role + model.ParticipantList + }{ + {model.RoleComposer, model.ParticipantList{ + toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"), + toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"), + }}, + {model.RoleLyricist, model.ParticipantList{ + toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"), + toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"), + }}, + {model.RoleArranger, model.ParticipantList{ + toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"), + toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"), + }}, + {model.RoleConductor, model.ParticipantList{ + toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"), + toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"), + }}, + {model.RoleDirector, model.ParticipantList{ + toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"), + toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"), + }}, + {model.RoleEngineer, model.ParticipantList{ + toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"), + toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"), + }}, + {model.RoleProducer, model.ParticipantList{ + toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"), + toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"), + }}, + {model.RoleRemixer, model.ParticipantList{ + toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"), + toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"), + }}, + {model.RoleDJMixer, model.ParticipantList{ + toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"), + toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"), + }}, + {model.RoleMixer, model.ParticipantList{ + toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"), + toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"), + }}, + } + + var e *extractor + + parseTestFile := func(path string) *model.MediaFile { + mds, err := e.Parse(path) + Expect(err).ToNot(HaveOccurred()) + + info, ok := mds[path] + Expect(ok).To(BeTrue()) + + fileInfo, err := os.Stat(path) + Expect(err).ToNot(HaveOccurred()) + info.FileInfo = testFileInfo{FileInfo: fileInfo} + + metadata := metadata.New(path, info) + mf := metadata.ToMediaFile(1, "folderID") + return &mf + } + + BeforeEach(func() { + e = &extractor{} + }) + + Describe("ReplayGain", func() { + DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) { + mf := parseTestFile("tests/fixtures/" + file) + + Expect(mf.RGTrackGain).To(Equal(trackGain)) + Expect(mf.RGTrackPeak).To(Equal(trackPeak)) + Expect(mf.RGAlbumGain).To(Equal(albumGain)) + Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) + }, + Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), + ) + }) + + Describe("lyrics", func() { + makeLyrics := func(code, secondLine string) model.Lyrics { + return model.Lyrics{ + DisplayArtist: "", + DisplayTitle: "", + Lang: code, + Line: []model.Line{ + {Start: gg.P(int64(0)), Value: "This is"}, + {Start: gg.P(int64(2500)), Value: secondLine}, + }, + Offset: nil, + Synced: true, + } + } + + It("should fetch both synced and unsynced lyrics in mixed flac", func() { + mf := parseTestFile("tests/fixtures/mixed-lyrics.flac") + + lyrics, err := mf.StructuredLyrics() + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(2)) + + Expect(lyrics[0].Synced).To(BeTrue()) + Expect(lyrics[1].Synced).To(BeFalse()) + }) + + It("should handle mp3 with uslt and sylt", func() { + mf := parseTestFile("tests/fixtures/test.mp3") + + lyrics, err := mf.StructuredLyrics() + Expect(err).ToNot(HaveOccurred()) + Expect(lyrics).To(HaveLen(4)) + + engSylt := makeLyrics("eng", "English SYLT") + engUslt := makeLyrics("eng", "English") + unsSylt := makeLyrics("xxx", "unspecified SYLT") + unsUslt := makeLyrics("xxx", "unspecified") + + // Why is the order inconsistent between runs? Nobody knows + Expect(lyrics).To(Or( + Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}), + Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}), + )) + }) + + DescribeTable("format-specific lyrics", func(file string, isId3 bool) { + mf := parseTestFile("tests/fixtures/" + file) + + lyrics, err := mf.StructuredLyrics() + Expect(err).To(Not(HaveOccurred())) + Expect(lyrics).To(HaveLen(2)) + + unspec := makeLyrics("xxx", "unspecified") + eng := makeLyrics("xxx", "English") + + if isId3 { + eng.Lang = "eng" + } + + Expect(lyrics).To(Or( + Equal(model.LyricList{unspec, eng}), + Equal(model.LyricList{eng, unspec}))) + }, + Entry("flac", "test.flac", false), + Entry("m4a", "test.m4a", false), + Entry("ogg", "test.ogg", false), + Entry("wma", "test.wma", false), + Entry("wv", "test.wv", false), + Entry("wav", "test.wav", true), + Entry("aiff", "test.aiff", true), + ) + }) + + Describe("Participants", func() { + DescribeTable("test tags consistent across formats", func(format string) { + mf := parseTestFile("tests/fixtures/test." + format) + + for _, data := range roles { + role := data.Role + artists := data.ParticipantList + + actual := mf.Participants[role] + Expect(actual).To(HaveLen(len(artists))) + + for i := range artists { + actualArtist := actual[i] + expectedArtist := artists[i] + + Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) + Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName)) + Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID)) + } + } + + if format != "m4a" { + performers := mf.Participants[model.RolePerformer] + Expect(performers).To(HaveLen(8)) + + rules := map[string][]string{ + "pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"}, + "pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""}, + "pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"}, + "pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"}, + "pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"}, + } + + for name, rule := range rules { + mbid := rule[0] + for i := 1; i < len(rule); i++ { + found := false + + for _, mapped := range performers { + if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] { + found = true + break + } + } + + Expect(found).To(BeTrue(), "Could not find matching artist") + } + } + } + }, + Entry("FLAC format", "flac"), + Entry("M4a format", "m4a"), + Entry("OGG format", "ogg"), + Entry("WV format", "wv"), + + Entry("MP3 format", "mp3"), + Entry("WAV format", "wav"), + Entry("AIFF format", "aiff"), + ) + + It("should parse wma", func() { + mf := parseTestFile("tests/fixtures/test.wma") + + for _, data := range roles { + role := data.Role + artists := data.ParticipantList + actual := mf.Participants[role] + + // WMA has no Arranger role + if role == model.RoleArranger { + Expect(actual).To(HaveLen(0)) + continue + } + + Expect(actual).To(HaveLen(len(artists)), role.String()) + + // For some bizarre reason, the order is inverted. We also don't get + // sort names or MBIDs + for i := range artists { + idx := len(artists) - 1 - i + + actualArtist := actual[i] + expectedArtist := artists[idx] + + Expect(actualArtist.Name).To(Equal(expectedArtist.Name)) + } + } + }) + }) +}) diff --git a/adapters/taglib/get_filename.go b/adapters/taglib/get_filename.go new file mode 100644 index 0000000..df7cab8 --- /dev/null +++ b/adapters/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/adapters/taglib/get_filename_win.go b/adapters/taglib/get_filename_win.go new file mode 100644 index 0000000..2093616 --- /dev/null +++ b/adapters/taglib/get_filename_win.go @@ -0,0 +1,96 @@ +//go:build windows + +package taglib + +// From https://github.com/orofarne/gowchar + +/* +#include + +const size_t SIZEOF_WCHAR_T = sizeof(wchar_t); + +void gowchar_set (wchar_t *arr, int pos, wchar_t val) +{ + arr[pos] = val; +} + +wchar_t gowchar_get (wchar_t *arr, int pos) +{ + return arr[pos]; +} +*/ +import "C" + +import ( + "fmt" + "unicode/utf16" + "unicode/utf8" +) + +var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T) + +func getFilename(s string) *C.wchar_t { + wstr, _ := StringToWcharT(s) + return wstr +} + +func StringToWcharT(s string) (*C.wchar_t, C.size_t) { + switch SIZEOF_WCHAR_T { + case 2: + return stringToWchar2(s) // Windows + case 4: + return stringToWchar4(s) // Unix + default: + panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T)) + } + panic("?!!") +} + +// Windows +func stringToWchar2(s string) (*C.wchar_t, C.size_t) { + var slen int + s1 := s + for len(s1) > 0 { + r, size := utf8.DecodeRuneInString(s1) + if er, _ := utf16.EncodeRune(r); er == '\uFFFD' { + slen += 1 + } else { + slen += 2 + } + s1 = s1[size:] + } + slen++ // \0 + res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T) + var i int + for len(s) > 0 { + r, size := utf8.DecodeRuneInString(s) + if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' { + C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1)) + i++ + C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2)) + i++ + } else { + C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r)) + i++ + } + s = s[size:] + } + C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0 + return (*C.wchar_t)(res), C.size_t(slen) +} + +// Unix +func stringToWchar4(s string) (*C.wchar_t, C.size_t) { + slen := utf8.RuneCountInString(s) + slen++ // \0 + res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T) + var i int + for len(s) > 0 { + r, size := utf8.DecodeRuneInString(s) + C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r)) + s = s[size:] + i++ + } + C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0 + return (*C.wchar_t)(res), C.size_t(slen) +} diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go new file mode 100644 index 0000000..d32adf4 --- /dev/null +++ b/adapters/taglib/taglib.go @@ -0,0 +1,178 @@ +package taglib + +import ( + "io/fs" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/storage/local" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" +) + +type extractor struct { + baseDir string +} + +func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) { + results := make(map[string]metadata.Info) + for _, path := range files { + props, err := e.extractMetadata(path) + if err != nil { + continue + } + results[path] = *props + } + return results, nil +} + +func (e extractor) Version() string { + return Version() +} + +func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { + fullPath := filepath.Join(e.baseDir, filePath) + tags, err := Read(fullPath) + if err != nil { + log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err) + return nil, err + } + + // Parse audio properties + ap := metadata.AudioProperties{} + ap.BitRate = parseProp(tags, "__bitrate") + ap.Channels = parseProp(tags, "__channels") + ap.SampleRate = parseProp(tags, "__samplerate") + ap.BitDepth = parseProp(tags, "__bitspersample") + length := parseProp(tags, "__lengthinmilliseconds") + ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10) + + // Extract basic tags + parseBasicTag(tags, "__title", "title") + parseBasicTag(tags, "__artist", "artist") + parseBasicTag(tags, "__album", "album") + parseBasicTag(tags, "__comment", "comment") + parseBasicTag(tags, "__genre", "genre") + parseBasicTag(tags, "__year", "year") + parseBasicTag(tags, "__track", "tracknumber") + + // Parse track/disc totals + parseTuple := func(prop string) { + tagName := prop + "number" + tagTotal := prop + "total" + if value, ok := tags[tagName]; ok && len(value) > 0 { + parts := strings.Split(value[0], "/") + tags[tagName] = []string{parts[0]} + if len(parts) == 2 { + tags[tagTotal] = []string{parts[1]} + } + } + } + parseTuple("track") + parseTuple("disc") + + // Adjust some ID3 tags + parseLyrics(tags) + parseTIPL(tags) + delete(tags, "tmcl") // TMCL is already parsed by TagLib + + return &metadata.Info{ + Tags: tags, + AudioProperties: ap, + HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true", + }, nil +} + +// parseLyrics make sure lyrics tags have language +func parseLyrics(tags map[string][]string) { + lyrics := tags["lyrics"] + if len(lyrics) > 0 { + tags["lyrics:xxx"] = lyrics + delete(tags, "lyrics") + } +} + +// These are the only roles we support, based on Picard's tag map: +// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html +var tiplMapping = map[string]string{ + "arranger": "arranger", + "engineer": "engineer", + "producer": "producer", + "mix": "mixer", + "DJ-mix": "djmixer", +} + +// parseProp parses a property from the tags map and sets it to the target integer. +// It also deletes the property from the tags map after parsing. +func parseProp(tags map[string][]string, prop string) int { + if value, ok := tags[prop]; ok && len(value) > 0 { + v, _ := strconv.Atoi(value[0]) + delete(tags, prop) + return v + } + return 0 +} + +// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map. +// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.), +// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag. +func parseBasicTag(tags map[string][]string, basicName string, tagName string) { + basicValue := tags[basicName] + if len(basicValue) == 0 { + return + } + delete(tags, basicName) + if len(tags[tagName]) == 0 { + tags[tagName] = basicValue + } +} + +// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format: +// +// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson". +// +// and breaks it down into a map of roles and names, e.g.: +// +// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}. +func parseTIPL(tags map[string][]string) { + tipl := tags["tipl"] + if len(tipl) == 0 { + return + } + + addRole := func(currentRole string, currentValue []string) { + if currentRole != "" && len(currentValue) > 0 { + role := tiplMapping[currentRole] + tags[role] = append(tags[role], strings.Join(currentValue, " ")) + } + } + + var currentRole string + var currentValue []string + for _, part := range strings.Split(tipl[0], " ") { + if _, ok := tiplMapping[part]; ok { + addRole(currentRole, currentValue) + currentRole = part + currentValue = nil + continue + } + currentValue = append(currentValue, part) + } + addRole(currentRole, currentValue) + delete(tags, "tipl") +} + +var _ local.Extractor = (*extractor)(nil) + +func init() { + local.RegisterExtractor("taglib", func(_ fs.FS, baseDir string) local.Extractor { + // ignores fs, as taglib extractor only works with local files + return &extractor{baseDir} + }) + conf.AddHook(func() { + log.Debug("TagLib version", "version", Version()) + }) +} diff --git a/adapters/taglib/taglib_suite_test.go b/adapters/taglib/taglib_suite_test.go new file mode 100644 index 0000000..2b26612 --- /dev/null +++ b/adapters/taglib/taglib_suite_test.go @@ -0,0 +1,17 @@ +package taglib + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestTagLib(t *testing.T) { + tests.Init(t, true) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "TagLib Suite") +} diff --git a/adapters/taglib/taglib_test.go b/adapters/taglib/taglib_test.go new file mode 100644 index 0000000..f24c0e8 --- /dev/null +++ b/adapters/taglib/taglib_test.go @@ -0,0 +1,296 @@ +package taglib + +import ( + "io/fs" + "os" + "strings" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Extractor", func() { + var e *extractor + + BeforeEach(func() { + e = &extractor{} + }) + + Describe("Parse", func() { + It("correctly parses metadata from all files in folder", func() { + mds, err := e.Parse( + "tests/fixtures/test.mp3", + "tests/fixtures/test.ogg", + ) + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(2)) + + // Test MP3 + m := mds["tests/fixtures/test.mp3"] + Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"})) + Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) + Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) + Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) + + Expect(m.HasPicture).To(BeTrue()) + Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s")) + Expect(m.AudioProperties.BitRate).To(Equal(192)) + Expect(m.AudioProperties.Channels).To(Equal(2)) + Expect(m.AudioProperties.SampleRate).To(Equal(44100)) + + Expect(m.Tags).To(Or( + HaveKeyWithValue("compilation", []string{"1"}), + HaveKeyWithValue("tcmp", []string{"1"})), + ) + Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) + Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"})) + Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"})) + Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"})) + Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"})) + Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) + Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) + Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"})) + Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"})) + + Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"})) + Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) + + Expect(m.Tags).ToNot(HaveKey("lyrics")) + Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{ + "[00:00.00]This is\n[00:02.50]English SYLT\n", + "[00:00.00]This is\n[00:02.50]English", + }), HaveKeyWithValue("lyrics:eng", []string{ + "[00:00.00]This is\n[00:02.50]English", + "[00:00.00]This is\n[00:02.50]English SYLT\n", + }))) + Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", + "[00:00.00]This is\n[00:02.50]unspecified", + }), HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + "[00:00.00]This is\n[00:02.50]unspecified SYLT\n", + }))) + + // Test OGG + m = mds["tests/fixtures/test.ogg"] + Expect(err).To(BeNil()) + Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"})) + + // TabLib 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.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_track_gain", []string{albumGain}), + )) + + Expect(m.Tags).To(Or( + HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}), + )) + Expect(m.Tags).To(Or( + HaveKeyWithValue("replaygain_track_gain", []string{trackGain}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}), + )) + Expect(m.Tags).To(Or( + HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}), + HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}), + )) + + Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"})) + Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"})) + Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"})) + Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"})) + Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"})) + Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"})) + + Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"})) + Expect(m.Tags).To(Or( + HaveKeyWithValue("tracknumber", []string{"3"}), + HaveKeyWithValue("tracknumber", []string{"3/10"}), + )) + if !strings.HasSuffix(file, "test.wma") { + // TODO Not sure why this is not working for WMA + Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"})) + } + Expect(m.Tags).To(Or( + HaveKeyWithValue("discnumber", []string{"1"}), + HaveKeyWithValue("discnumber", []string{"1/2"}), + )) + Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"})) + + // WMA does not have a "compilation" tag, but "wm/iscompilation" + Expect(m.Tags).To(Or( + HaveKeyWithValue("compilation", []string{"1"}), + HaveKeyWithValue("wm/iscompilation", []string{"1"})), + ) + + if id3Lyrics { + Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{ + "[00:00.00]This is\n[00:02.50]English", + })) + Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + })) + } else { + Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + "[00:00.00]This is\n[00:02.50]English", + })) + } + + Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"})) + }, + + // ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac + Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true), + + Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), + Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true), + Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true), + + // ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma + // Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order + Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true), + + // ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv + Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true), + + // ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav + Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true), + + // ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff + Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true), + ) + + // Skip these tests when running as root + Context("Access Forbidden", func() { + var accessForbiddenFile string + var RegularUserContext = XContext + var isRegularUser = os.Getuid() != 0 + if isRegularUser { + RegularUserContext = Context + } + + // Only run permission tests if we are not root + RegularUserContext("when run without root privileges", func() { + BeforeEach(func() { + accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3") + + f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + Expect(f.Close()).To(Succeed()) + Expect(os.Remove(accessForbiddenFile)).To(Succeed()) + }) + }) + + It("correctly handle unreadable file due to insufficient read permission", func() { + _, err := e.extractMetadata(accessForbiddenFile) + Expect(err).To(MatchError(os.ErrPermission)) + }) + + It("skips the file if it cannot be read", func() { + files := []string{ + "tests/fixtures/test.mp3", + "tests/fixtures/test.ogg", + accessForbiddenFile, + } + mds, err := e.Parse(files...) + Expect(err).NotTo(HaveOccurred()) + Expect(mds).To(HaveLen(2)) + Expect(mds).ToNot(HaveKey(accessForbiddenFile)) + }) + }) + }) + + }) + + Describe("Error Checking", func() { + It("returns a generic ErrPath if file does not exist", func() { + testFilePath := "tests/fixtures/NON_EXISTENT.ogg" + _, err := e.extractMetadata(testFilePath) + Expect(err).To(MatchError(fs.ErrNotExist)) + }) + It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() { + // File has an empty TDAT frame + md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3") + Expect(err).ToNot(HaveOccurred()) + Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"})) + }) + }) + + Describe("parseTIPL", func() { + var tags map[string][]string + + BeforeEach(func() { + tags = make(map[string][]string) + }) + + Context("when the TIPL string is populated", func() { + It("correctly parses roles and names", func() { + tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"} + parseTIPL(tags) + Expect(tags["arranger"]).To(ConsistOf("Andrew Powell")) + Expect(tags["engineer"]).To(ConsistOf("Chris Blair")) + Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe")) + }) + + It("handles multiple names for a single role", func() { + tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"} + parseTIPL(tags) + Expect(tags["producer"]).To(ConsistOf("Eric Woolfson")) + Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) + }) + + It("discards roles without names", func() { + tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"} + parseTIPL(tags) + Expect(tags).ToNot(HaveKey("producer")) + Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair")) + }) + }) + + Context("when the TIPL string is empty", func() { + It("does nothing", func() { + tags["tipl"] = []string{""} + parseTIPL(tags) + Expect(tags).To(BeEmpty()) + }) + }) + + Context("when the TIPL is not present", func() { + It("does nothing", func() { + parseTIPL(tags) + Expect(tags).To(BeEmpty()) + }) + }) + }) + +}) diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp new file mode 100644 index 0000000..2985e8f --- /dev/null +++ b/adapters/taglib/taglib_wrapper.cpp @@ -0,0 +1,299 @@ +#include +#include + +#define TAGLIB_STATIC +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "taglib_wrapper.h" + +char has_cover(const TagLib::FileRef f); + +static char TAGLIB_VERSION[16]; + +char* taglib_version() { + snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION); + return (char *)TAGLIB_VERSION; +} + +int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) { + TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast); + + if (f.isNull()) { + return TAGLIB_ERR_PARSE; + } + + if (!f.audioProperties()) { + return TAGLIB_ERR_AUDIO_PROPS; + } + + // Add audio properties to the tags + const TagLib::AudioProperties *props(f.audioProperties()); + goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds()); + goPutInt(id, (char *)"__bitrate", props->bitrate()); + goPutInt(id, (char *)"__channels", props->channels()); + goPutInt(id, (char *)"__samplerate", props->sampleRate()); + + // Extract bits per sample for supported formats + int bitsPerSample = 0; + if (const auto* apeProperties{ dynamic_cast(props) }) + bitsPerSample = apeProperties->bitsPerSample(); + else if (const auto* asfProperties{ dynamic_cast(props) }) + bitsPerSample = asfProperties->bitsPerSample(); + else if (const auto* flacProperties{ dynamic_cast(props) }) + bitsPerSample = flacProperties->bitsPerSample(); + else if (const auto* mp4Properties{ dynamic_cast(props) }) + bitsPerSample = mp4Properties->bitsPerSample(); + else if (const auto* wavePackProperties{ dynamic_cast(props) }) + bitsPerSample = wavePackProperties->bitsPerSample(); + else if (const auto* aiffProperties{ dynamic_cast(props) }) + bitsPerSample = aiffProperties->bitsPerSample(); + else if (const auto* wavProperties{ dynamic_cast(props) }) + bitsPerSample = wavProperties->bitsPerSample(); + else if (const auto* dsfProperties{ dynamic_cast(props) }) + bitsPerSample = dsfProperties->bitsPerSample(); + + if (bitsPerSample > 0) { + goPutInt(id, (char *)"__bitspersample", bitsPerSample); + } + + // Send all properties to the Go map + TagLib::PropertyMap tags = f.file()->properties(); + + // Make sure at least the basic properties are extracted + TagLib::Tag *basic = f.file()->tag(); + if (!basic->isEmpty()) { + if (!basic->title().isEmpty()) { + tags.insert("__title", basic->title()); + } + if (!basic->artist().isEmpty()) { + tags.insert("__artist", basic->artist()); + } + if (!basic->album().isEmpty()) { + tags.insert("__album", basic->album()); + } + if (!basic->comment().isEmpty()) { + tags.insert("__comment", basic->comment()); + } + if (!basic->genre().isEmpty()) { + tags.insert("__genre", basic->genre()); + } + if (basic->year() > 0) { + tags.insert("__year", TagLib::String::number(basic->year())); + } + if (basic->track() > 0) { + tags.insert("__track", TagLib::String::number(basic->track())); + } + } + + TagLib::ID3v2::Tag *id3Tags = NULL; + + // Get some extended/non-standard ID3-only tags (ex: iTunes extended frames) + TagLib::MPEG::File *mp3File(dynamic_cast(f.file())); + if (mp3File != NULL) { + id3Tags = mp3File->ID3v2Tag(); + } + + if (id3Tags == NULL) { + TagLib::RIFF::WAV::File *wavFile(dynamic_cast(f.file())); + if (wavFile != NULL && wavFile->hasID3v2Tag()) { + id3Tags = wavFile->ID3v2Tag(); + } + } + + if (id3Tags == NULL) { + TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast(f.file())); + if (aiffFile && aiffFile->hasID3v2Tag()) { + id3Tags = aiffFile->tag(); + } + } + + // Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems + // with many players, so they will not be parsed + + if (id3Tags != NULL) { + const auto &frames = id3Tags->frameListMap(); + + for (const auto &kv: frames) { + if (kv.first == "USLT") { + for (const auto &tag: kv.second) { + TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast(tag); + if (frame == NULL) continue; + + tags.erase("LYRICS"); + + const auto bv = frame->language(); + char language[4] = {'x', 'x', 'x', '\0'}; + if (bv.size() == 3) { + strncpy(language, bv.data(), 3); + } + + char *val = const_cast(frame->text().toCString(true)); + + goPutLyrics(id, language, val); + } + } else if (kv.first == "SYLT") { + for (const auto &tag: kv.second) { + TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast(tag); + if (frame == NULL) continue; + + const auto bv = frame->language(); + char language[4] = {'x', 'x', 'x', '\0'}; + if (bv.size() == 3) { + strncpy(language, bv.data(), 3); + } + + const auto format = frame->timestampFormat(); + if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) { + + for (const auto &line: frame->synchedText()) { + char *text = const_cast(line.text.toCString(true)); + goPutLyricLine(id, language, text, line.time); + } + } else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) { + const int sampleRate = props->sampleRate(); + + if (sampleRate != 0) { + for (const auto &line: frame->synchedText()) { + const int timeInMs = (line.time * 1000) / sampleRate; + char *text = const_cast(line.text.toCString(true)); + goPutLyricLine(id, language, text, timeInMs); + } + } + } + } + } else if (kv.first == "TIPL"){ + if (!kv.second.isEmpty()) { + tags.insert(kv.first, kv.second.front()->toString()); + } + } + } + } + + // M4A may have some iTunes specific tags not captured by the PropertyMap interface + TagLib::MP4::File *m4afile(dynamic_cast(f.file())); + if (m4afile != NULL) { + const auto itemListMap = m4afile->tag()->itemMap(); + for (const auto item: itemListMap) { + char *key = const_cast(item.first.toCString(true)); + for (const auto value: item.second.toStringList()) { + char *val = const_cast(value.toCString(true)); + goPutM4AStr(id, key, val); + } + } + } + + // WMA/ASF files may have additional tags not captured by the PropertyMap interface + TagLib::ASF::File *asfFile(dynamic_cast(f.file())); + if (asfFile != NULL) { + const TagLib::ASF::Tag *asfTags{asfFile->tag()}; + const auto itemListMap = asfTags->attributeListMap(); + for (const auto item : itemListMap) { + char *key = const_cast(item.first.toCString(true)); + + for (auto j = item.second.begin(); + j != item.second.end(); ++j) { + + char *val = const_cast(j->toString().toCString(true)); + goPutStr(id, key, val); + } + } + } + + // Send all collected tags to the Go map + for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end(); + ++i) { + char *key = const_cast(i->first.toCString(true)); + for (TagLib::StringList::ConstIterator j = i->second.begin(); + j != i->second.end(); ++j) { + char *val = const_cast((*j).toCString(true)); + goPutStr(id, key, val); + } + } + + // Cover art has to be handled separately + if (has_cover(f)) { + goPutStr(id, (char *)"has_picture", (char *)"true"); + } + + return 0; +} + +// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise. +char has_cover(const TagLib::FileRef f) { + char hasCover = 0; + // ----- MP3 + if (TagLib::MPEG::File * mp3File{dynamic_cast(f.file())}) { + if (mp3File->ID3v2Tag()) { + const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()}; + hasCover = !frameListMap["APIC"].isEmpty(); + } + } + // ----- FLAC + else if (TagLib::FLAC::File * flacFile{dynamic_cast(f.file())}) { + hasCover = !flacFile->pictureList().isEmpty(); + } + // ----- MP4 + else if (TagLib::MP4::File * mp4File{dynamic_cast(f.file())}) { + auto &coverItem{mp4File->tag()->itemMap()["covr"]}; + TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()}; + hasCover = !coverArtList.isEmpty(); + } + // ----- Ogg + else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast(f.file())}) { + hasCover = !vorbisFile->tag()->pictureList().isEmpty(); + } + // ----- Opus + else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast(f.file())}) { + hasCover = !opusFile->tag()->pictureList().isEmpty(); + } + // ----- WAV + else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast(f.file()) }) { + if (wavFile->hasID3v2Tag()) { + const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() }; + hasCover = !frameListMap["APIC"].isEmpty(); + } + } + // ----- AIFF + else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast(f.file())}) { + if (aiffFile->hasID3v2Tag()) { + const auto& frameListMap{ aiffFile->tag()->frameListMap() }; + hasCover = !frameListMap["APIC"].isEmpty(); + } + } + // ----- WMA + else if (TagLib::ASF::File * asfFile{dynamic_cast(f.file())}) { + const TagLib::ASF::Tag *tag{ asfFile->tag() }; + hasCover = tag && tag->attributeListMap().contains("WM/Picture"); + } + // ----- DSF + else if (TagLib::DSF::File * dsffile{ dynamic_cast(f.file())}) { + const TagLib::ID3v2::Tag *tag { dsffile->tag() }; + hasCover = tag && !tag->frameListMap()["APIC"].isEmpty(); + } + // ----- WAVPAK (APE tag) + else if (TagLib::WavPack::File * wvFile{dynamic_cast(f.file())}) { + if (wvFile->hasAPETag()) { + // This is the particular string that Picard uses + hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty(); + } + } + + return hasCover; +} diff --git a/adapters/taglib/taglib_wrapper.go b/adapters/taglib/taglib_wrapper.go new file mode 100644 index 0000000..4a97992 --- /dev/null +++ b/adapters/taglib/taglib_wrapper.go @@ -0,0 +1,157 @@ +package taglib + +/* +#cgo !windows pkg-config: --define-prefix taglib +#cgo windows pkg-config: taglib +#cgo illumos LDFLAGS: -lstdc++ -lsendfile +#cgo linux darwin CXXFLAGS: -std=c++11 +#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib +#include +#include +#include +#include "taglib_wrapper.h" +*/ +import "C" +import ( + "encoding/json" + "fmt" + "os" + "runtime/debug" + "strconv" + "strings" + "sync" + "sync/atomic" + "unsafe" + + "github.com/navidrome/navidrome/log" +) + +const iTunesKeyPrefix = "----:com.apple.itunes:" + +func Version() string { + return C.GoString(C.taglib_version()) +} + +func Read(filename string) (tags map[string][]string, err error) { + // Do not crash on failures in the C code/library + debug.SetPanicOnFault(true) + defer func() { + if r := recover(); r != nil { + log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r) + err = fmt.Errorf("extractor: recovered from panic: %s", r) + } + }() + + fp := getFilename(filename) + defer C.free(unsafe.Pointer(fp)) + id, m, release := newMap() + defer release() + + log.Trace("extractor: reading tags", "filename", filename, "map_id", id) + res := C.taglib_read(fp, C.ulong(id)) + switch res { + case C.TAGLIB_ERR_PARSE: + // Check additional case whether the file is unreadable due to permission + file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600) + defer file.Close() + + if os.IsPermission(fileErr) { + return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr) + } else if fileErr != nil { + return nil, fmt.Errorf("cannot parse file media file: %w", fileErr) + } else { + return nil, fmt.Errorf("cannot parse file media file") + } + case C.TAGLIB_ERR_AUDIO_PROPS: + return nil, fmt.Errorf("can't get audio properties from file") + } + if log.IsGreaterOrEqualTo(log.LevelDebug) { + j, _ := json.Marshal(m) + log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id) + } else { + log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id) + } + + return m, nil +} + +type tagMap map[string][]string + +var allMaps sync.Map +var mapsNextID atomic.Uint32 + +func newMap() (uint32, tagMap, func()) { + id := mapsNextID.Add(1) + + m := tagMap{} + allMaps.Store(id, m) + + return id, m, func() { + allMaps.Delete(id) + } +} + +func doPutTag(id C.ulong, key string, val *C.char) { + if key == "" { + return + } + + r, _ := allMaps.Load(uint32(id)) + m := r.(tagMap) + k := strings.ToLower(key) + v := strings.TrimSpace(C.GoString(val)) + m[k] = append(m[k], v) +} + +//export goPutM4AStr +func goPutM4AStr(id C.ulong, key *C.char, val *C.char) { + k := C.GoString(key) + + // Special for M4A, do not catch keys that have no actual name + k = strings.TrimPrefix(k, iTunesKeyPrefix) + doPutTag(id, k, val) +} + +//export goPutStr +func goPutStr(id C.ulong, key *C.char, val *C.char) { + doPutTag(id, C.GoString(key), val) +} + +//export goPutInt +func goPutInt(id C.ulong, key *C.char, val C.int) { + valStr := strconv.Itoa(int(val)) + vp := C.CString(valStr) + defer C.free(unsafe.Pointer(vp)) + goPutStr(id, key, vp) +} + +//export goPutLyrics +func goPutLyrics(id C.ulong, lang *C.char, val *C.char) { + doPutTag(id, "lyrics:"+C.GoString(lang), val) +} + +//export goPutLyricLine +func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) { + language := C.GoString(lang) + line := C.GoString(text) + timeGo := int64(time) + + ms := timeGo % 1000 + timeGo /= 1000 + sec := timeGo % 60 + timeGo /= 60 + minimum := timeGo % 60 + formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line) + + key := "lyrics:" + language + + r, _ := allMaps.Load(uint32(id)) + m := r.(tagMap) + k := strings.ToLower(key) + existing, ok := m[k] + if ok { + existing[0] += formattedLine + } else { + m[k] = []string{formattedLine} + } +} diff --git a/adapters/taglib/taglib_wrapper.h b/adapters/taglib/taglib_wrapper.h new file mode 100644 index 0000000..c93f4c1 --- /dev/null +++ b/adapters/taglib/taglib_wrapper.h @@ -0,0 +1,24 @@ +#define TAGLIB_ERR_PARSE -1 +#define TAGLIB_ERR_AUDIO_PROPS -2 + +#ifdef __cplusplus +extern "C" { +#endif + +#ifdef WIN32 +#define FILENAME_CHAR_T wchar_t +#else +#define FILENAME_CHAR_T char +#endif + +extern void goPutM4AStr(unsigned long id, char *key, char *val); +extern void goPutStr(unsigned long id, char *key, char *val); +extern void goPutInt(unsigned long id, char *key, int val); +extern void goPutLyrics(unsigned long id, char *lang, char *val); +extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time); +int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id); +char* taglib_version(); + +#ifdef __cplusplus +} +#endif diff --git a/cmd/backup.go b/cmd/backup.go new file mode 100644 index 0000000..ab73f75 --- /dev/null +++ b/cmd/backup.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + "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", "", "path of backup database to restore") + 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 = backupDir + } + + idx := strings.LastIndex(conf.Server.DbPath, "?") + var path string + + if idx == -1 { + path = conf.Server.DbPath + } else { + path = conf.Server.DbPath[:idx] + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) + return + } + + start := time.Now() + path, err := db.Backup(ctx) + if err != nil { + log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err) + } + + elapsed := time.Since(start) + log.Info("Backup complete", "elapsed", elapsed, "path", path) +} + +func runPrune(ctx context.Context) { + if backupDir != "" { + conf.Server.Backup.Path = backupDir + } + + if backupCount != -1 { + conf.Server.Backup.Count = backupCount + } + + if conf.Server.Backup.Count == 0 && !force { + fmt.Println("Warning: pruning ALL backups") + fmt.Printf("Please enter YES (all caps) to continue: ") + var input string + _, err := fmt.Scanln(&input) + + if input != "YES" || err != nil { + log.Warn("Prune cancelled") + return + } + } + + idx := strings.LastIndex(conf.Server.DbPath, "?") + var path string + + if idx == -1 { + path = conf.Server.DbPath + } else { + path = conf.Server.DbPath[:idx] + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) + return + } + + start := time.Now() + count, err := db.Prune(ctx) + if err != nil { + log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err) + } + + elapsed := time.Since(start) + + log.Info("Prune complete", "elapsed", elapsed, "successfully pruned", count) +} + +func runRestore(ctx context.Context) { + idx := strings.LastIndex(conf.Server.DbPath, "?") + var path string + + if idx == -1 { + path = conf.Server.DbPath + } else { + path = conf.Server.DbPath[:idx] + } + + if _, err := os.Stat(path); os.IsNotExist(err) { + log.Fatal("No existing database", "path", path) + return + } + + if !force { + fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") + fmt.Printf("Please enter YES (all caps) to continue: ") + var input string + _, err := fmt.Scanln(&input) + + if input != "YES" || err != nil { + log.Warn("Restore cancelled") + return + } + } + + start := time.Now() + err := db.Restore(ctx, restorePath) + if err != nil { + log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err) + } + + elapsed := time.Since(start) + log.Info("Restore complete", "elapsed", elapsed) +} diff --git a/cmd/cmd_suite_test.go b/cmd/cmd_suite_test.go new file mode 100644 index 0000000..f2ddf6a --- /dev/null +++ b/cmd/cmd_suite_test.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCmd(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Cmd Suite") +} diff --git a/cmd/index.go b/cmd/index.go new file mode 100644 index 0000000..21d0d7e --- /dev/null +++ b/cmd/index.go @@ -0,0 +1,35 @@ +package cmd + +import ( + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/spf13/cobra" +) + +var indexCmd = &cobra.Command{ + Use: "index", + Short: "Manage Meilisearch index", +} + +var indexFullCmd = &cobra.Command{ + Use: "full", + Short: "Full re-index of all media files, albums, and artists", + Run: func(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + defer db.Init(ctx)() + + ds := CreateDataStore() + + err := ds.ReindexAll(ctx) + if err != nil { + log.Error("Error during full re-index", err) + } else { + log.Info("Full re-index completed successfully") + } + }, +} + +func init() { + rootCmd.AddCommand(indexCmd) + indexCmd.AddCommand(indexFullCmd) +} diff --git a/cmd/inspect.go b/cmd/inspect.go new file mode 100644 index 0000000..9f9270b --- /dev/null +++ b/cmd/inspect.go @@ -0,0 +1,79 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "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(args) + }, +} + +var marshalers = map[string]func(interface{}) ([]byte, error){ + "pretty": prettyMarshal, + "toml": toml.Marshal, + "yaml": yaml.Marshal, + "json": json.Marshal, + "jsonindent": func(v interface{}) ([]byte, error) { + return json.MarshalIndent(v, "", " ") + }, +} + +func prettyMarshal(v interface{}) ([]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(args []string) { + marshal := marshalers[format] + if marshal == nil { + log.Fatal("Invalid format", "format", format) + } + var out []core.InspectOutput + for _, filePath := range args { + if !model.IsAudioFile(filePath) { + log.Warn("Not an audio file", "file", filePath) + continue + } + output, err := core.Inspect(filePath, 1, "") + 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)) +} diff --git a/cmd/pls.go b/cmd/pls.go new file mode 100644 index 0000000..9b94c9e --- /dev/null +++ b/cmd/pls.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/spf13/cobra" +) + +var ( + playlistID string + outputFile string + userID string + outputFormat 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) +} + +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()) + }, + } +) + +func runExporter(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Fatal("Error retrieving playlist", "name", playlistID, err) + } + if errors.Is(err, model.ErrNotFound) { + playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}}) + if err != nil { + log.Fatal("Error retrieving playlist", "name", playlistID, err) + } + if len(playlists) > 0 { + playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false) + if err != nil { + log.Fatal("Error retrieving playlist", "name", playlistID, err) + } + } + } + if playlist == nil { + log.Fatal("Playlist not found", "name", playlistID) + } + pls := playlist.ToM3U8() + if outputFile == "-" || outputFile == "" { + println(pls) + return + } + + err = os.WriteFile(outputFile, []byte(pls), 0600) + if err != nil { + log.Fatal("Error writing to the output file", "file", outputFile, err) + } +} + +func runList(ctx context.Context) { + if outputFormat != "csv" && outputFormat != "json" { + log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) + } + + ds, ctx := getAdminContext(ctx) + options := model.QueryOptions{Sort: "owner_name"} + + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + options.Filters = squirrel.Eq{"owner_id": user.ID} + } + + playlists, err := ds.Playlist(ctx).GetAll(options) + if err != nil { + log.Fatal(ctx, "Failed to retrieve playlists", err) + } + + if outputFormat == "csv" { + w := csv.NewWriter(os.Stdout) + _ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"}) + for _, playlist := range playlists { + _ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)}) + } + w.Flush() + } else { + display := make(displayPlaylists, len(playlists)) + for idx, playlist := range playlists { + display[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) + } +} diff --git a/cmd/plugin.go b/cmd/plugin.go new file mode 100644 index 0000000..0f3b660 --- /dev/null +++ b/cmd/plugin.go @@ -0,0 +1,716 @@ +package cmd + +import ( + "cmp" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/spf13/cobra" +) + +const ( + pluginPackageExtension = ".ndp" + pluginDirPermissions = 0700 + pluginFilePermissions = 0600 +) + +func init() { + pluginCmd := &cobra.Command{ + Use: "plugin", + Short: "Manage Navidrome plugins", + Long: "Commands for managing Navidrome plugins", + } + + listCmd := &cobra.Command{ + Use: "list", + Short: "List installed plugins", + Long: "List all installed plugins with their metadata", + Run: pluginList, + } + + infoCmd := &cobra.Command{ + Use: "info [pluginPackage|pluginName]", + Short: "Show details of a plugin", + Long: "Show detailed information about a plugin package (.ndp file) or an installed plugin", + Args: cobra.ExactArgs(1), + Run: pluginInfo, + } + + installCmd := &cobra.Command{ + Use: "install [pluginPackage]", + Short: "Install a plugin from a .ndp file", + Long: "Install a Navidrome Plugin Package (.ndp) file", + Args: cobra.ExactArgs(1), + Run: pluginInstall, + } + + removeCmd := &cobra.Command{ + Use: "remove [pluginName]", + Short: "Remove an installed plugin", + Long: "Remove a plugin by name", + Args: cobra.ExactArgs(1), + Run: pluginRemove, + } + + updateCmd := &cobra.Command{ + Use: "update [pluginPackage]", + Short: "Update an existing plugin", + Long: "Update an installed plugin with a new version from a .ndp file", + Args: cobra.ExactArgs(1), + Run: pluginUpdate, + } + + refreshCmd := &cobra.Command{ + Use: "refresh [pluginName]", + Short: "Reload a plugin without restarting Navidrome", + Long: "Reload and recompile a plugin without needing to restart Navidrome", + Args: cobra.ExactArgs(1), + Run: pluginRefresh, + } + + devCmd := &cobra.Command{ + Use: "dev [folder_path]", + Short: "Create symlink to development folder", + Long: "Create a symlink from a plugin development folder to the plugins directory for easier development", + Args: cobra.ExactArgs(1), + Run: pluginDev, + } + + pluginCmd.AddCommand(listCmd, infoCmd, installCmd, removeCmd, updateCmd, refreshCmd, devCmd) + rootCmd.AddCommand(pluginCmd) +} + +// Validation helpers + +func validatePluginPackageFile(path string) error { + if !utils.FileExists(path) { + return fmt.Errorf("plugin package not found: %s", path) + } + if filepath.Ext(path) != pluginPackageExtension { + return fmt.Errorf("not a valid plugin package: %s (expected %s extension)", path, pluginPackageExtension) + } + return nil +} + +func validatePluginDirectory(pluginsDir, pluginName string) (string, error) { + pluginDir := filepath.Join(pluginsDir, pluginName) + if !utils.FileExists(pluginDir) { + return "", fmt.Errorf("plugin not found: %s (path: %s)", pluginName, pluginDir) + } + return pluginDir, nil +} + +func resolvePluginPath(pluginDir string) (resolvedPath string, isSymlink bool, err error) { + // Check if it's a directory or a symlink + lstat, err := os.Lstat(pluginDir) + if err != nil { + return "", false, fmt.Errorf("failed to stat plugin: %w", err) + } + + isSymlink = lstat.Mode()&os.ModeSymlink != 0 + + if isSymlink { + // Resolve the symlink target + targetDir, err := os.Readlink(pluginDir) + if err != nil { + return "", true, fmt.Errorf("failed to resolve symlink: %w", err) + } + + // If target is a relative path, make it absolute + if !filepath.IsAbs(targetDir) { + targetDir = filepath.Join(filepath.Dir(pluginDir), targetDir) + } + + // Verify the target exists and is a directory + targetInfo, err := os.Stat(targetDir) + if err != nil { + return "", true, fmt.Errorf("failed to access symlink target %s: %w", targetDir, err) + } + + if !targetInfo.IsDir() { + return "", true, fmt.Errorf("symlink target is not a directory: %s", targetDir) + } + + return targetDir, true, nil + } else if !lstat.IsDir() { + return "", false, fmt.Errorf("not a valid plugin directory: %s", pluginDir) + } + + return pluginDir, false, nil +} + +// Package handling helpers + +func loadAndValidatePackage(ndpPath string) (*plugins.PluginPackage, error) { + if err := validatePluginPackageFile(ndpPath); err != nil { + return nil, err + } + + pkg, err := plugins.LoadPackage(ndpPath) + if err != nil { + return nil, fmt.Errorf("failed to load plugin package: %w", err) + } + + return pkg, nil +} + +func extractAndSetupPlugin(ndpPath, targetDir string) error { + if err := plugins.ExtractPackage(ndpPath, targetDir); err != nil { + return fmt.Errorf("failed to extract plugin package: %w", err) + } + + ensurePluginDirPermissions(targetDir) + return nil +} + +// Display helpers + +func displayPluginTableRow(w *tabwriter.Writer, discovery plugins.PluginDiscoveryEntry) { + if discovery.Error != nil { + // Handle global errors (like directory read failure) + if discovery.ID == "" { + log.Error("Failed to read plugins directory", "folder", conf.Server.Plugins.Folder, discovery.Error) + return + } + // Handle individual plugin errors - show them in the table + fmt.Fprintf(w, "%s\tERROR\tERROR\tERROR\tERROR\t%v\n", discovery.ID, discovery.Error) + return + } + + // Mark symlinks with an indicator + nameDisplay := discovery.Manifest.Name + if discovery.IsSymlink { + nameDisplay = nameDisplay + " (dev)" + } + + // Convert capabilities to strings + capabilities := slice.Map(discovery.Manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { + return string(cap) + }) + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + discovery.ID, + nameDisplay, + cmp.Or(discovery.Manifest.Author, "-"), + cmp.Or(discovery.Manifest.Version, "-"), + strings.Join(capabilities, ", "), + cmp.Or(discovery.Manifest.Description, "-")) +} + +func displayTypedPermissions(permissions schema.PluginManifestPermissions, indent string) { + if permissions.Http != nil { + fmt.Printf("%shttp:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Http.Reason) + fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Http.AllowLocalNetwork) + fmt.Printf("%s Allowed URLs:\n", indent) + for urlPattern, methodEnums := range permissions.Http.AllowedUrls { + methods := make([]string, len(methodEnums)) + for i, methodEnum := range methodEnums { + methods[i] = string(methodEnum) + } + fmt.Printf("%s %s: [%s]\n", indent, urlPattern, strings.Join(methods, ", ")) + } + fmt.Println() + } + + if permissions.Config != nil { + fmt.Printf("%sconfig:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Config.Reason) + fmt.Println() + } + + if permissions.Scheduler != nil { + fmt.Printf("%sscheduler:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Scheduler.Reason) + fmt.Println() + } + + if permissions.Websocket != nil { + fmt.Printf("%swebsocket:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Websocket.Reason) + fmt.Printf("%s Allow Local Network: %t\n", indent, permissions.Websocket.AllowLocalNetwork) + fmt.Printf("%s Allowed URLs: [%s]\n", indent, strings.Join(permissions.Websocket.AllowedUrls, ", ")) + fmt.Println() + } + + if permissions.Cache != nil { + fmt.Printf("%scache:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Cache.Reason) + fmt.Println() + } + + if permissions.Artwork != nil { + fmt.Printf("%sartwork:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Artwork.Reason) + fmt.Println() + } + + if permissions.Subsonicapi != nil { + allowedUsers := "All Users" + if len(permissions.Subsonicapi.AllowedUsernames) > 0 { + allowedUsers = strings.Join(permissions.Subsonicapi.AllowedUsernames, ", ") + } + fmt.Printf("%ssubsonicapi:\n", indent) + fmt.Printf("%s Reason: %s\n", indent, permissions.Subsonicapi.Reason) + fmt.Printf("%s Allow Admins: %t\n", indent, permissions.Subsonicapi.AllowAdmins) + fmt.Printf("%s Allowed Usernames: [%s]\n", indent, allowedUsers) + fmt.Println() + } +} + +func displayPluginDetails(manifest *schema.PluginManifest, fileInfo *pluginFileInfo, permInfo *pluginPermissionInfo) { + fmt.Println("\nPlugin Information:") + fmt.Printf(" Name: %s\n", manifest.Name) + fmt.Printf(" Author: %s\n", manifest.Author) + fmt.Printf(" Version: %s\n", manifest.Version) + fmt.Printf(" Description: %s\n", manifest.Description) + + fmt.Print(" Capabilities: ") + capabilities := make([]string, len(manifest.Capabilities)) + for i, cap := range manifest.Capabilities { + capabilities[i] = string(cap) + } + fmt.Print(strings.Join(capabilities, ", ")) + fmt.Println() + + // Display manifest permissions using the typed permissions + fmt.Println(" Required Permissions:") + displayTypedPermissions(manifest.Permissions, " ") + + // Print file information if available + if fileInfo != nil { + fmt.Println("Package Information:") + fmt.Printf(" File: %s\n", fileInfo.path) + fmt.Printf(" Size: %d bytes (%.2f KB)\n", fileInfo.size, float64(fileInfo.size)/1024) + fmt.Printf(" SHA-256: %s\n", fileInfo.hash) + fmt.Printf(" Modified: %s\n", fileInfo.modTime.Format(time.RFC3339)) + } + + // Print file permissions information if available + if permInfo != nil { + fmt.Println("File Permissions:") + fmt.Printf(" Plugin Directory: %s (%s)\n", permInfo.dirPath, permInfo.dirMode) + if permInfo.isSymlink { + fmt.Printf(" Symlink Target: %s (%s)\n", permInfo.targetPath, permInfo.targetMode) + } + fmt.Printf(" Manifest File: %s\n", permInfo.manifestMode) + if permInfo.wasmMode != "" { + fmt.Printf(" WASM File: %s\n", permInfo.wasmMode) + } + } +} + +type pluginFileInfo struct { + path string + size int64 + hash string + modTime time.Time +} + +type pluginPermissionInfo struct { + dirPath string + dirMode string + isSymlink bool + targetPath string + targetMode string + manifestMode string + wasmMode string +} + +func getFileInfo(path string) *pluginFileInfo { + fileInfo, err := os.Stat(path) + if err != nil { + log.Error("Failed to get file information", err) + return nil + } + + return &pluginFileInfo{ + path: path, + size: fileInfo.Size(), + hash: calculateSHA256(path), + modTime: fileInfo.ModTime(), + } +} + +func getPermissionInfo(pluginDir string) *pluginPermissionInfo { + // Get plugin directory permissions + dirInfo, err := os.Lstat(pluginDir) + if err != nil { + log.Error("Failed to get plugin directory permissions", err) + return nil + } + + permInfo := &pluginPermissionInfo{ + dirPath: pluginDir, + dirMode: dirInfo.Mode().String(), + } + + // Check if it's a symlink + if dirInfo.Mode()&os.ModeSymlink != 0 { + permInfo.isSymlink = true + + // Get target path and permissions + targetPath, err := os.Readlink(pluginDir) + if err == nil { + if !filepath.IsAbs(targetPath) { + targetPath = filepath.Join(filepath.Dir(pluginDir), targetPath) + } + permInfo.targetPath = targetPath + + if targetInfo, err := os.Stat(targetPath); err == nil { + permInfo.targetMode = targetInfo.Mode().String() + } + } + } + + // Get manifest file permissions + manifestPath := filepath.Join(pluginDir, "manifest.json") + if manifestInfo, err := os.Stat(manifestPath); err == nil { + permInfo.manifestMode = manifestInfo.Mode().String() + } + + // Get WASM file permissions (look for .wasm files) + entries, err := os.ReadDir(pluginDir) + if err == nil { + for _, entry := range entries { + if filepath.Ext(entry.Name()) == ".wasm" { + wasmPath := filepath.Join(pluginDir, entry.Name()) + if wasmInfo, err := os.Stat(wasmPath); err == nil { + permInfo.wasmMode = wasmInfo.Mode().String() + break // Just show the first WASM file found + } + } + } + } + + return permInfo +} + +// Command implementations + +func pluginList(cmd *cobra.Command, args []string) { + discoveries := plugins.DiscoverPlugins(conf.Server.Plugins.Folder) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tAUTHOR\tVERSION\tCAPABILITIES\tDESCRIPTION") + + for _, discovery := range discoveries { + displayPluginTableRow(w, discovery) + } + w.Flush() +} + +func pluginInfo(cmd *cobra.Command, args []string) { + path := args[0] + pluginsDir := conf.Server.Plugins.Folder + + var manifest *schema.PluginManifest + var fileInfo *pluginFileInfo + var permInfo *pluginPermissionInfo + + if filepath.Ext(path) == pluginPackageExtension { + // It's a package file + pkg, err := loadAndValidatePackage(path) + if err != nil { + log.Fatal("Failed to load plugin package", err) + } + manifest = pkg.Manifest + fileInfo = getFileInfo(path) + // No permission info for package files + } else { + // It's a plugin name + pluginDir, err := validatePluginDirectory(pluginsDir, path) + if err != nil { + log.Fatal("Plugin validation failed", err) + } + + manifest, err = plugins.LoadManifest(pluginDir) + if err != nil { + log.Fatal("Failed to load plugin manifest", err) + } + + // Get permission info for installed plugins + permInfo = getPermissionInfo(pluginDir) + } + + displayPluginDetails(manifest, fileInfo, permInfo) +} + +func pluginInstall(cmd *cobra.Command, args []string) { + ndpPath := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pkg, err := loadAndValidatePackage(ndpPath) + if err != nil { + log.Fatal("Package validation failed", err) + } + + // Create target directory based on plugin name + targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name) + + // Check if plugin already exists + if utils.FileExists(targetDir) { + log.Fatal("Plugin already installed", "name", pkg.Manifest.Name, "path", targetDir, + "use", "navidrome plugin update") + } + + if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil { + log.Fatal("Plugin installation failed", err) + } + + fmt.Printf("Plugin '%s' v%s installed successfully\n", pkg.Manifest.Name, pkg.Manifest.Version) +} + +func pluginRemove(cmd *cobra.Command, args []string) { + pluginName := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pluginDir, err := validatePluginDirectory(pluginsDir, pluginName) + if err != nil { + log.Fatal("Plugin validation failed", err) + } + + _, isSymlink, err := resolvePluginPath(pluginDir) + if err != nil { + log.Fatal("Failed to resolve plugin path", err) + } + + if isSymlink { + // For symlinked plugins (dev mode), just remove the symlink + if err := os.Remove(pluginDir); err != nil { + log.Fatal("Failed to remove plugin symlink", "name", pluginName, err) + } + fmt.Printf("Development plugin symlink '%s' removed successfully (target directory preserved)\n", pluginName) + } else { + // For regular plugins, remove the entire directory + if err := os.RemoveAll(pluginDir); err != nil { + log.Fatal("Failed to remove plugin directory", "name", pluginName, err) + } + fmt.Printf("Plugin '%s' removed successfully\n", pluginName) + } +} + +func pluginUpdate(cmd *cobra.Command, args []string) { + ndpPath := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pkg, err := loadAndValidatePackage(ndpPath) + if err != nil { + log.Fatal("Package validation failed", err) + } + + // Check if plugin exists + targetDir := filepath.Join(pluginsDir, pkg.Manifest.Name) + if !utils.FileExists(targetDir) { + log.Fatal("Plugin not found", "name", pkg.Manifest.Name, "path", targetDir, + "use", "navidrome plugin install") + } + + // Create a backup of the existing plugin + backupDir := targetDir + ".bak." + time.Now().Format("20060102150405") + if err := os.Rename(targetDir, backupDir); err != nil { + log.Fatal("Failed to backup existing plugin", err) + } + + // Extract the new package + if err := extractAndSetupPlugin(ndpPath, targetDir); err != nil { + // Restore backup if extraction failed + os.RemoveAll(targetDir) + _ = os.Rename(backupDir, targetDir) // Ignore error as we're already in a fatal path + log.Fatal("Plugin update failed", err) + } + + // Remove the backup + os.RemoveAll(backupDir) + + fmt.Printf("Plugin '%s' updated to v%s successfully\n", pkg.Manifest.Name, pkg.Manifest.Version) +} + +func pluginRefresh(cmd *cobra.Command, args []string) { + pluginName := args[0] + pluginsDir := conf.Server.Plugins.Folder + + pluginDir, err := validatePluginDirectory(pluginsDir, pluginName) + if err != nil { + log.Fatal("Plugin validation failed", err) + } + + resolvedPath, isSymlink, err := resolvePluginPath(pluginDir) + if err != nil { + log.Fatal("Failed to resolve plugin path", err) + } + + if isSymlink { + log.Debug("Processing symlinked plugin", "name", pluginName, "link", pluginDir, "target", resolvedPath) + } + + fmt.Printf("Refreshing plugin '%s'...\n", pluginName) + + // Get the plugin manager and refresh + mgr := GetPluginManager(cmd.Context()) + log.Debug("Scanning plugins directory", "path", pluginsDir) + mgr.ScanPlugins() + + log.Info("Waiting for plugin compilation to complete", "name", pluginName) + + // Wait for compilation to complete + if err := mgr.EnsureCompiled(pluginName); err != nil { + log.Fatal("Failed to compile refreshed plugin", "name", pluginName, err) + } + + log.Info("Plugin compilation completed successfully", "name", pluginName) + fmt.Printf("Plugin '%s' refreshed successfully\n", pluginName) +} + +func pluginDev(cmd *cobra.Command, args []string) { + sourcePath, err := filepath.Abs(args[0]) + if err != nil { + log.Fatal("Invalid path", "path", args[0], err) + } + pluginsDir := conf.Server.Plugins.Folder + + // Validate source directory and manifest + if err := validateDevSource(sourcePath); err != nil { + log.Fatal("Source validation failed", err) + } + + // Load manifest to get plugin name + manifest, err := plugins.LoadManifest(sourcePath) + if err != nil { + log.Fatal("Failed to load plugin manifest", "path", filepath.Join(sourcePath, "manifest.json"), err) + } + + pluginName := cmp.Or(manifest.Name, filepath.Base(sourcePath)) + targetPath := filepath.Join(pluginsDir, pluginName) + + // Handle existing target + if err := handleExistingTarget(targetPath, sourcePath); err != nil { + log.Fatal("Failed to handle existing target", err) + } + + // Create target directory if needed + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + log.Fatal("Failed to create plugins directory", "path", filepath.Dir(targetPath), err) + } + + // Create the symlink + if err := os.Symlink(sourcePath, targetPath); err != nil { + log.Fatal("Failed to create symlink", "source", sourcePath, "target", targetPath, err) + } + + fmt.Printf("Development symlink created: '%s' -> '%s'\n", targetPath, sourcePath) + fmt.Println("Plugin can be refreshed with: navidrome plugin refresh", pluginName) +} + +// Utility functions + +func validateDevSource(sourcePath string) error { + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + return fmt.Errorf("source folder not found: %s (%w)", sourcePath, err) + } + if !sourceInfo.IsDir() { + return fmt.Errorf("source path is not a directory: %s", sourcePath) + } + + manifestPath := filepath.Join(sourcePath, "manifest.json") + if !utils.FileExists(manifestPath) { + return fmt.Errorf("source folder missing manifest.json: %s", sourcePath) + } + + return nil +} + +func handleExistingTarget(targetPath, sourcePath string) error { + if !utils.FileExists(targetPath) { + return nil // Nothing to handle + } + + // Check if it's already a symlink to our source + existingLink, err := os.Readlink(targetPath) + if err == nil && existingLink == sourcePath { + fmt.Printf("Symlink already exists and points to the correct source\n") + return fmt.Errorf("symlink already exists") // This will cause early return in caller + } + + // Handle case where target exists but is not a symlink to our source + fmt.Printf("Target path '%s' already exists.\n", targetPath) + fmt.Print("Do you want to replace it? (y/N): ") + var response string + _, err = fmt.Scanln(&response) + if err != nil || strings.ToLower(response) != "y" { + if err != nil { + log.Debug("Error reading input, assuming 'no'", err) + } + return fmt.Errorf("operation canceled") + } + + // Remove existing target + if err := os.RemoveAll(targetPath); err != nil { + return fmt.Errorf("failed to remove existing target %s: %w", targetPath, err) + } + + return nil +} + +func ensurePluginDirPermissions(dir string) { + if err := os.Chmod(dir, pluginDirPermissions); err != nil { + log.Error("Failed to set plugin directory permissions", "dir", dir, err) + } + + // Apply permissions to all files in the directory + entries, err := os.ReadDir(dir) + if err != nil { + log.Error("Failed to read plugin directory", "dir", dir, err) + return + } + + for _, entry := range entries { + path := filepath.Join(dir, entry.Name()) + info, err := os.Stat(path) + if err != nil { + log.Error("Failed to stat file", "path", path, err) + continue + } + + mode := os.FileMode(pluginFilePermissions) // Files + if info.IsDir() { + mode = os.FileMode(pluginDirPermissions) // Directories + ensurePluginDirPermissions(path) // Recursive + } + + if err := os.Chmod(path, mode); err != nil { + log.Error("Failed to set file permissions", "path", path, err) + } + } +} + +func calculateSHA256(filePath string) string { + file, err := os.Open(filePath) + if err != nil { + log.Error("Failed to open file for hashing", err) + return "N/A" + } + defer file.Close() + + hasher := sha256.New() + if _, err := io.Copy(hasher, file); err != nil { + log.Error("Failed to calculate hash", err) + return "N/A" + } + + return hex.EncodeToString(hasher.Sum(nil)) +} diff --git a/cmd/plugin_test.go b/cmd/plugin_test.go new file mode 100644 index 0000000..3a4aefa --- /dev/null +++ b/cmd/plugin_test.go @@ -0,0 +1,193 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" +) + +var _ = Describe("Plugin CLI Commands", func() { + var tempDir string + var cmd *cobra.Command + var stdOut *os.File + var origStdout *os.File + var outReader *os.File + + // Helper to create a test plugin with the given name and details + createTestPlugin := func(name, author, version string, capabilities []string) string { + pluginDir := filepath.Join(tempDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Create a properly formatted capabilities JSON array + capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"` + + manifest := `{ + "name": "` + name + `", + "author": "` + author + `", + "version": "` + version + `", + "description": "Plugin for testing", + "website": "https://test.navidrome.org/` + name + `", + "capabilities": [` + capabilitiesJSON + `], + "permissions": {} + }` + + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + + // Create a dummy WASM file + wasmContent := []byte("dummy wasm content for testing") + Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed()) + + return pluginDir + } + + // Helper to execute a command and return captured output + captureOutput := func(reader io.Reader) string { + stdOut.Close() + outputBytes, err := io.ReadAll(reader) + Expect(err).NotTo(HaveOccurred()) + return string(outputBytes) + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + + // Setup config + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tempDir + + // Create a command for testing + cmd = &cobra.Command{Use: "test"} + + // Setup stdout capture + origStdout = os.Stdout + var err error + outReader, stdOut, err = os.Pipe() + Expect(err).NotTo(HaveOccurred()) + os.Stdout = stdOut + + DeferCleanup(func() { + os.Stdout = origStdout + }) + }) + + AfterEach(func() { + os.Stdout = origStdout + if stdOut != nil { + stdOut.Close() + } + if outReader != nil { + outReader.Close() + } + }) + + Describe("Plugin list command", func() { + It("should list installed plugins", func() { + // Create test plugins + createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"}) + createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"}) + + // Execute command + pluginList(cmd, []string{}) + + // Verify output + output := captureOutput(outReader) + + Expect(output).To(ContainSubstring("plugin1")) + Expect(output).To(ContainSubstring("Test Author")) + Expect(output).To(ContainSubstring("1.0.0")) + Expect(output).To(ContainSubstring("MetadataAgent")) + + Expect(output).To(ContainSubstring("plugin2")) + Expect(output).To(ContainSubstring("Another Author")) + Expect(output).To(ContainSubstring("2.1.0")) + Expect(output).To(ContainSubstring("Scrobbler")) + }) + }) + + Describe("Plugin info command", func() { + It("should display information about an installed plugin", func() { + // Create test plugin with multiple capabilities + createTestPlugin("test-plugin", "Test Author", "1.0.0", + []string{"MetadataAgent", "Scrobbler"}) + + // Execute command + pluginInfo(cmd, []string{"test-plugin"}) + + // Verify output + output := captureOutput(outReader) + + Expect(output).To(ContainSubstring("Name: test-plugin")) + Expect(output).To(ContainSubstring("Author: Test Author")) + Expect(output).To(ContainSubstring("Version: 1.0.0")) + Expect(output).To(ContainSubstring("Description: Plugin for testing")) + Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler")) + }) + }) + + Describe("Plugin remove command", func() { + It("should remove a regular plugin directory", func() { + // Create test plugin + pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0", + []string{"MetadataAgent"}) + + // Execute command + pluginRemove(cmd, []string{"regular-plugin"}) + + // Verify output + output := captureOutput(outReader) + Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully")) + + // Verify directory is actually removed + _, err := os.Stat(pluginDir) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("should remove only the symlink for a development plugin", func() { + // Create a real source directory + sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source") + Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed()) + + manifest := `{ + "name": "dev-plugin", + "author": "Dev Author", + "version": "0.1.0", + "description": "Development plugin for testing", + "website": "https://test.navidrome.org/dev-plugin", + "capabilities": ["Scrobbler"], + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + + // Create a dummy WASM file + wasmContent := []byte("dummy wasm content for testing") + Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed()) + + // Create a symlink in the plugins directory + symlinkPath := filepath.Join(tempDir, "dev-plugin") + Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed()) + + // Execute command + pluginRemove(cmd, []string{"dev-plugin"}) + + // Verify output + output := captureOutput(outReader) + Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully")) + Expect(output).To(ContainSubstring("target directory preserved")) + + // Verify the symlink is removed but source directory exists + _, err := os.Lstat(symlinkPath) + Expect(os.IsNotExist(err)).To(BeTrue()) + + _, err = os.Stat(sourceDir) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..5e91ecd --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,404 @@ +package cmd + +import ( + "context" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/go-chi/chi/v5/middleware" + _ "github.com/navidrome/navidrome/adapters/taglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "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" + "golang.org/x/sync/errgroup" +) + +var ( + cfgFile string + noBanner bool + + rootCmd = &cobra.Command{ + Use: "navidrome", + Short: "Navidrome is a self-hosted music server and streamer", + Long: `Navidrome is a self-hosted music server and streamer. +Complete documentation is available at https://www.navidrome.org/docs`, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + preRun() + }, + Run: func(cmd *cobra.Command, args []string) { + runNavidrome(cmd.Context()) + }, + PostRun: func(cmd *cobra.Command, args []string) { + postRun() + }, + 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) + } +} + +func preRun() { + if !noBanner { + println(resources.Banner()) + } + conf.Load(noBanner) +} + +func postRun() { + 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 will log it and exit. If the process receives a signal to exit, +// it will cancel the context and exit gracefully. +func runNavidrome(ctx context.Context) { + defer db.Init(ctx)() + + g, ctx := errgroup.WithContext(ctx) + g.Go(startServer(ctx)) + g.Go(startSignaller(ctx)) + g.Go(startScheduler(ctx)) + g.Go(startPlaybackServer(ctx)) + g.Go(schedulePeriodicBackup(ctx)) + g.Go(startInsightsCollector(ctx)) + g.Go(scheduleDBOptimizer(ctx)) + g.Go(startPluginManager(ctx)) + 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") + } + + if err := g.Wait(); err != nil { + log.Error("Fatal error in Navidrome. Aborting", err) + } +} + +// 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.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter()) + if conf.Server.LastFM.Enabled { + a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter()) + } + if conf.Server.ListenBrainz.Enabled { + a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter()) + } + 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()) + } + if conf.Server.DevEnableProfiler { + a.MountRouter("Profiling", "/debug", middleware.Profiler()) + } + if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { + a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler()) + } + return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey) + } +} + +// 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 + if schedule == "" { + log.Info(ctx, "Periodic scan is DISABLED") + return nil + } + + s := CreateScanner(ctx) + 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) + } + }) + if err != nil { + log.Error(ctx, "Error scheduling periodic scan", err) + } + return nil + } +} + +func pidHashChanged(ds model.DataStore) (bool, error) { + pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "") + if err != nil { + return false, err + } + pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "") + if err != nil { + return false, err + } + return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), 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(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0") + if err != nil { + return err + } + inProgress, err := ds.Library(ctx).ScanInProgress() + if err != nil { + return err + } + pidHasChanged, err := pidHashChanged(ds) + if err != nil { + return err + } + scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged + 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(ctx).Delete(consts.FullScanAfterMigrationFlagKey) + case pidHasChanged: + log.Warn(ctx, "PID config changed, performing full scan") + fullScanRequired = "1" + 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") + } + 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 scheduleDBOptimizer(ctx context.Context) func() error { + return func() error { + log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule) + schedulerInstance := scheduler.GetInstance() + _, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() { + if scanner.IsScanning() { + log.Debug(ctx, "Skipping DB optimization because a scan is in progress") + return + } + db.Optimize(ctx) + }) + return err + } +} + +// startScheduler starts the Navidrome scheduler, which is used to run periodic tasks. +func startScheduler(ctx context.Context) func() error { + 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 + } +} + +// 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) + } +} + +// startPluginManager starts the plugin manager, if configured. +func startPluginManager(ctx context.Context) func() error { + return func() error { + if !conf.Server.Plugins.Enabled { + log.Debug("Plugins are DISABLED") + return nil + } + log.Info(ctx, "Starting plugin manager") + // Get the manager instance and scan for plugins + manager := GetPluginManager(ctx) + manager.ScanPlugins() + + return nil + } +} + +// TODO: Implement some struct tags to map flags to viper +func init() { + cobra.OnInitialize(func() { + conf.InitConfig(cfgFile, true) + }) + + rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`) + rootCmd.PersistentFlags().BoolVarP(&noBanner, "nobanner", "n", false, `don't show banner`) + rootCmd.PersistentFlags().String("musicfolder", viper.GetString("musicfolder"), "folder where your music is stored") + 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().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")) + + _ = viper.BindPFlag("sessiontimeout", rootCmd.Flags().Lookup("sessiontimeout")) + _ = viper.BindPFlag("scaninterval", rootCmd.Flags().Lookup("scaninterval")) + _ = viper.BindPFlag("uiloginbackgroundurl", rootCmd.Flags().Lookup("uiloginbackgroundurl")) + + _ = viper.BindPFlag("prometheus.enabled", rootCmd.Flags().Lookup("prometheus.enabled")) + _ = 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/scan.go b/cmd/scan.go new file mode 100644 index 0000000..e587b89 --- /dev/null +++ b/cmd/scan.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "context" + "encoding/gob" + "os" + + "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/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/utils/pl" + "github.com/spf13/cobra" +) + +var ( + fullScan bool + subprocess bool + targets []string +) + +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\")") + rootCmd.AddCommand(scanCmd) +} + +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "Scan music folder", + Long: "Scan music folder for updates", + Run: func(cmd *cobra.Command, args []string) { + runScanner(cmd.Context()) + }, +} + +func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) { + 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) + } + // Discard the progress status, we only care about errors + } + + if fullScan { + log.Info("Finished full rescan") + } else { + log.Info("Finished rescan") + } +} + +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 := core.NewPlaylists(ds) + + // Parse targets if provided + var scanTargets []model.ScanTarget + if len(targets) > 0 { + var err error + 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)) + } + + 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 { + trackScanInteractively(ctx, progress) + } +} diff --git a/cmd/signaller_nounix.go b/cmd/signaller_nounix.go new file mode 100644 index 0000000..de488cb --- /dev/null +++ b/cmd/signaller_nounix.go @@ -0,0 +1,14 @@ +//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/signaller_unix.go b/cmd/signaller_unix.go new file mode 100644 index 0000000..f47dbf4 --- /dev/null +++ b/cmd/signaller_unix.go @@ -0,0 +1,40 @@ +//go:build !windows && !plan9 + +package cmd + +import ( + "context" + "os" + "os/signal" + "syscall" + "time" + + "github.com/navidrome/navidrome/log" +) + +const triggerScanSignal = syscall.SIGUSR1 + +func startSignaller(ctx context.Context) func() error { + log.Info(ctx, "Starting signaler") + scanner := CreateScanner(ctx) + + return func() error { + var sigChan = make(chan os.Signal, 1) + signal.Notify(sigChan, triggerScanSignal) + + for { + select { + case sig := <-sigChan: + log.Info(ctx, "Received signal, triggering a new scan", "signal", sig) + start := time.Now() + _, err := scanner.ScanAll(ctx, false) + if err != nil { + log.Error(ctx, "Error scanning", err) + } + log.Info(ctx, "Triggered scan complete", "elapsed", time.Since(start)) + case <-ctx.Done(): + return nil + } + } + } +} diff --git a/cmd/svc.go b/cmd/svc.go new file mode 100644 index 0000000..e277bd4 --- /dev/null +++ b/cmd/svc.go @@ -0,0 +1,267 @@ +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 + 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() { + runNavidrome(p.ctx) + close(p.done) + }() + 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"] = "1 2 8 SIGKILL" + options["UserService"] = false + options["LogDirectory"] = conf.Server.DataFolder + options["SystemdScript"] = systemdScript + if conf.Server.LogFile != "" { + options["LogOutput"] = false + } else { + options["LogOutput"] = true + options["LogDirectory"] = conf.Server.DataFolder + } + 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) + if conf.Server.LogFile != "" { + println(" log file: " + conf.Server.LogFile) + } else { + println(" logs folder: " + conf.Server.DataFolder) + } + 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 $i, $dep := .Dependencies}} +{{$dep}} {{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}} + +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/user.go b/cmd/user.go new file mode 100644 index 0000000..1abf157 --- /dev/null +++ b/cmd/user.go @@ -0,0 +1,477 @@ +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(ctx).FindByUsername(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(ctx).GetAll(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(ctx).GetAll() + if err != nil { + return err + } + } + + err = tx.User(ctx).Put(&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(ctx).SetUserLibraries(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(ctx).CountAll() + 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(ctx).Delete(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(ctx).GetAll(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(ctx).GetAll() + 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(ctx).Put(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(ctx).SetUserLibraries(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) + + users, err := ds.User(ctx).ReadAll() + if err != nil { + log.Fatal(ctx, "Failed to retrieve users", err) + } + + userList := users.(model.Users) + + 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 new file mode 100644 index 0000000..81d646c --- /dev/null +++ b/cmd/utils.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "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" +) + +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(ctx).FindByUsername(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(ctx).Get(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 new file mode 100644 index 0000000..d7b6a3a --- /dev/null +++ b/cmd/wire_gen.go @@ -0,0 +1,211 @@ +// Code generated by Wire. DO NOT EDIT. + +//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo" +//go:build !wireinject +// +build !wireinject + +package cmd + +import ( + "context" + "github.com/google/wire" + "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/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/scrobbler" + "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/events" + "github.com/navidrome/navidrome/server/nativeapi" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic" +) + +import ( + _ "github.com/navidrome/navidrome/adapters/taglib" +) + +// Injectors from wire_injectors.go: + +func CreateDataStore() model.DataStore { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + return dataStore +} + +func CreateServer() *server.Server { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + broker := events.GetBroker() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) + serverServer := server.New(dataStore, broker, insights) + return serverServer +} + +func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + share := core.NewShare(dataStore) + playlists := core.NewPlaylists(dataStore) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + agentsAgents := agents.GetAgents(dataStore, manager) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + broker := events.GetBroker() + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + watcher := scanner.GetWatcher(dataStore, modelScanner) + library := core.NewLibrary(dataStore, modelScanner, watcher, broker) + maintenance := core.NewMaintenance(dataStore) + router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance) + return router +} + +func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + transcodingCache := core.GetTranscodingCache() + mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + share := core.NewShare(dataStore) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + players := core.NewPlayers(dataStore) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + broker := events.GetBroker() + playlists := core.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager) + playbackServer := playback.GetInstance(dataStore) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics) + return router +} + +func CreatePublicRouter() *public.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + transcodingCache := core.GetTranscodingCache() + mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache) + share := core.NewShare(dataStore) + archiver := core.NewArchiver(mediaStreamer, dataStore, share) + router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver) + return router +} + +func CreateLastFMRouter() *lastfm.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + router := lastfm.NewRouter(dataStore) + return router +} + +func CreateListenBrainzRouter() *listenbrainz.Router { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + router := listenbrainz.NewRouter(dataStore) + return router +} + +func CreateInsights() metrics.Insights { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + insights := metrics.GetInstance(dataStore, manager) + 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) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + broker := events.GetBroker() + playlists := core.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics) + return modelScanner +} + +func CreateScanWatcher(ctx context.Context) scanner.Watcher { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + fileCache := artwork.GetImageCache() + fFmpeg := ffmpeg.New() + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + agentsAgents := agents.GetAgents(dataStore, manager) + provider := external.NewProvider(dataStore, agentsAgents) + artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider) + cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache) + broker := events.GetBroker() + playlists := core.NewPlaylists(dataStore) + modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, 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 getPluginManager() plugins.Manager { + sqlDB := db.Db() + dataStore := persistence.New(sqlDB) + metricsMetrics := metrics.GetPrometheusInstance(dataStore) + manager := plugins.GetManager(dataStore, metricsMetrics) + return manager +} + +// wire_injectors.go: + +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) + +func GetPluginManager(ctx context.Context) plugins.Manager { + manager := getPluginManager() + manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx)) + return manager +} diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go new file mode 100644 index 0000000..595d406 --- /dev/null +++ b/cmd/wire_injectors.go @@ -0,0 +1,133 @@ +//go:build wireinject + +package cmd + +import ( + "context" + + "github.com/google/wire" + "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/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/scrobbler" + "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/events" + "github.com/navidrome/navidrome/server/nativeapi" + "github.com/navidrome/navidrome/server/public" + "github.com/navidrome/navidrome/server/subsonic" +) + +var allProviders = wire.NewSet( + core.Set, + artwork.Set, + server.New, + subsonic.New, + nativeapi.New, + public.New, + persistence.New, + lastfm.NewRouter, + listenbrainz.NewRouter, + events.GetBroker, + scanner.New, + scanner.GetWatcher, + plugins.GetManager, + metrics.GetPrometheusInstance, + db.Db, + wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), + wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), + wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), + wire.Bind(new(core.Watcher), new(scanner.Watcher)), +) + +func CreateDataStore() model.DataStore { + panic(wire.Build( + allProviders, + )) +} + +func CreateServer() *server.Server { + 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 CreatePublicRouter() *public.Router { + panic(wire.Build( + allProviders, + )) +} + +func CreateLastFMRouter() *lastfm.Router { + panic(wire.Build( + allProviders, + )) +} + +func CreateListenBrainzRouter() *listenbrainz.Router { + panic(wire.Build( + allProviders, + )) +} + +func CreateInsights() metrics.Insights { + panic(wire.Build( + allProviders, + )) +} + +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 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/buildtags.go b/conf/buildtags/buildtags.go new file mode 100644 index 0000000..5fc1250 --- /dev/null +++ b/conf/buildtags/buildtags.go @@ -0,0 +1,4 @@ +package buildtags + +// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all +// required build tags are disabled. diff --git a/conf/buildtags/netgo.go b/conf/buildtags/netgo.go new file mode 100644 index 0000000..0062ad2 --- /dev/null +++ b/conf/buildtags/netgo.go @@ -0,0 +1,11 @@ +//go:build netgo + +package buildtags + +// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project. +// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go` +// file requires it. + +// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700 + +var NETGO = true diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go new file mode 100644 index 0000000..b947e62 --- /dev/null +++ b/conf/configtest/configtest.go @@ -0,0 +1,10 @@ +package configtest + +import "github.com/navidrome/navidrome/conf" + +func SetupConfig() func() { + oldValues := *conf.Server + return func() { + conf.Server = &oldValues + } +} diff --git a/conf/configuration.go b/conf/configuration.go new file mode 100644 index 0000000..1637161 --- /dev/null +++ b/conf/configuration.go @@ -0,0 +1,709 @@ +package conf + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/bmatcuk/doublestar/v4" + "github.com/go-viper/encoding/ini" + "github.com/kr/pretty" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/run" + "github.com/robfig/cron/v3" + "github.com/spf13/viper" +) + +type configOptions struct { + ConfigFile string + Address string + Port int + UnixSocketPerm string + MusicFolder string + DataFolder string + CacheFolder string + 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 + EnableTranscodingCancellation bool + EnableDownloads bool + EnableExternalServices bool + EnableInsightsCollector bool + EnableMediaFileCoverArt bool + TranscodingCacheSize string + ImageCacheSize string + AlbumPlayCountMode string + EnableArtworkPrecache bool + AutoImportPlaylists bool + DefaultPlaylistPublicVisibility bool + PlaylistsPath string + SmartPlaylistRefreshDelay time.Duration + AutoTranscodeDownload bool + DefaultDownsamplingFormat string + SearchFullString bool + RecentlyAddedByModTime bool + PreferSortTags bool + IgnoredArticles string + IndexGroups string + FFmpegPath string + MPVPath string + MPVCmdTemplate string + CoverArtPriority string + CoverJpegQuality int + ArtistArtPriority string + LyricsPriority string + EnableGravatar bool + EnableFavourites bool + EnableStarRating bool + EnableUserEditing bool + EnableSharing bool + ShareURL string + DefaultShareExpiration time.Duration + DefaultDownloadableShare bool + DefaultTheme string + DefaultLanguage string + DefaultUIVolume int + EnableReplayGain bool + EnableCoverAnimation bool + EnableNowPlaying bool + GATrackingID string + EnableLogRedacting bool + AuthRequestLimit int + AuthWindowLength time.Duration + PasswordEncryptionKey string + ExtAuth extAuthOptions + Plugins pluginsOptions + PluginConfig map[string]map[string]string + HTTPSecurityHeaders secureOptions `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"` + LastFM lastfmOptions `json:",omitzero"` + Spotify spotifyOptions `json:",omitzero"` + Deezer deezerOptions `json:",omitzero"` + ListenBrainz listenBrainzOptions `json:",omitzero"` + EnableScrobbleHistory bool + Tags map[string]TagConf `json:",omitempty"` + Agents string + Meilisearch meilisearchOptions `json:",omitzero"` + + // 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 + DevArtistInfoTimeToLive time.Duration + DevAlbumInfoTimeToLive time.Duration + DevExternalScanner bool + DevScannerThreads uint + DevSelectiveWatcher bool + DevInsightsInitialDelay time.Duration + DevEnablePlayerInsights bool + DevEnablePluginsInsights bool + DevPluginCompilationTimeout time.Duration + DevExternalArtistFetchMultiplier float64 + DevOptimizeDB bool + DevPreserveUnicodeInExternalCalls bool +} + +type meilisearchOptions struct { + Enabled bool + Host string + ApiKey string +} + +type scannerOptions struct { + Enabled bool + Schedule string + WatcherWait time.Duration + ScanOnStartup bool + Extractor string + ArtistJoiner string + GenreSeparators string // Deprecated: Use Tags.genre.Split instead + GroupAlbumReleases bool // Deprecated: Use PID.Album instead + FollowSymlinks bool // Whether to follow symlinks when scanning directories + PurgeMissing string // Values: "never", "always", "full" +} + +type subsonicOptions struct { + AppendSubtitle bool + ArtistParticipations bool + DefaultReportRealPath bool + LegacyClients 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"` +} + +type lastfmOptions struct { + Enabled bool + ApiKey string + Secret string + Language string + ScrobbleFirstArtistOnly bool +} + +type spotifyOptions struct { + ID string + Secret string +} + +type deezerOptions struct { + Enabled bool + Language string +} + +type listenBrainzOptions struct { + Enabled bool + BaseURL string +} + +type secureOptions struct { + CustomFrameOptionsValue string +} + +type prometheusOptions struct { + Enabled bool + MetricsPath string + Password string +} + +type AudioDeviceDefinition []string + +type jukeboxOptions struct { + Enabled bool + Devices []AudioDeviceDefinition + Default string + AdminOnly bool +} + +type backupOptions struct { + Count int + Path string + 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 string + CacheSize string +} + +type extAuthOptions struct { + TrustedSources string + UserHeader string +} + +var ( + Server = &configOptions{} + hooks []func() +) + +func LoadFromFile(confFile string) { + viper.SetConfigFile(confFile) + err := viper.ReadInConfig() + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err) + os.Exit(1) + } + Load(true) +} + +func Load(noConfigDump bool) { + parseIniFileConfiguration() + + // Map deprecated options to their new names for backwards compatibility + mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources") + mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader") + + err := viper.Unmarshal(&Server) + if err != nil { + _, _ = 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:", err) + os.Exit(1) + } + + if Server.CacheFolder == "" { + Server.CacheFolder = filepath.Join(Server.DataFolder, "cache") + } + err = os.MkdirAll(Server.CacheFolder, os.ModePerm) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err) + os.Exit(1) + } + + if Server.Plugins.Enabled { + if Server.Plugins.Folder == "" { + Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") + } + err = os.MkdirAll(Server.Plugins.Folder, 0700) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err) + os.Exit(1) + } + } + + Server.ConfigFile = viper.GetViper().ConfigFileUsed() + if Server.DbPath == "" { + Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) + } + + if Server.Backup.Path != "" { + err = os.MkdirAll(Server.Backup.Path, os.ModePerm) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err) + os.Exit(1) + } + } + + out := os.Stderr + if Server.LogFile != "" { + out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error()) + os.Exit(1) + } + log.SetOutput(out) + } + + log.SetLevelString(Server.LogLevel) + log.SetLogLevels(Server.DevLogLevels) + log.SetLogSourceLine(Server.DevLogSourceLine) + log.SetRedacting(Server.EnableLogRedacting) + + err = run.Sequentially( + validateScanSchedule, + validateBackupSchedule, + validatePlaylistsPath, + validatePurgeMissingOption, + ) + if err != nil { + os.Exit(1) + } + + if Server.BaseURL != "" { + u, err := url.Parse(Server.BaseURL) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err) + os.Exit(1) + } + Server.BasePath = u.Path + u.Path = "" + u.RawQuery = "" + Server.BaseHost = u.Host + Server.BaseScheme = u.Scheme + } + + // Log configuration source + if Server.ConfigFile != "" { + log.Info("Loaded configuration", "file", Server.ConfigFile) + } 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 Server.EnableLogRedacting { + prettyConf = log.Redact(prettyConf) + } + _, _ = fmt.Fprintln(out, prettyConf) + } + + if !Server.EnableExternalServices { + disableExternalServices() + } + + if Server.Scanner.Extractor != consts.DefaultScannerExtractor { + log.Warn(fmt.Sprintf("Extractor '%s' is not implemented, using 'taglib'", Server.Scanner.Extractor)) + Server.Scanner.Extractor = consts.DefaultScannerExtractor + } + logDeprecatedOptions("Scanner.GenreSeparators") + logDeprecatedOptions("Scanner.GroupAlbumReleases") + logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored + logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader") + + // Call init hooks + for _, hook := range hooks { + hook() + } +} + +func logDeprecatedOptions(options ...string) { + for _, option := range options { + envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_")) + if os.Getenv(envVar) != "" { + log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", envVar)) + } + if viper.InConfig(option) { + log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release", option)) + } + } +} + +// 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) { + if viper.IsSet(legacyName) { + viper.Set(newName, viper.Get(legacyName)) + } +} + +// 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]interface{} + err := viper.Unmarshal(&iniConfig) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) + os.Exit(1) + } + cfg, ok := iniConfig["default"].(map[string]any) + if !ok { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig) + os.Exit(1) + } + err = viper.MergeConfigMap(cfg) + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err) + os.Exit(1) + } + } +} + +func disableExternalServices() { + log.Info("All external integrations are DISABLED!") + Server.EnableInsightsCollector = false + Server.LastFM.Enabled = false + Server.Spotify.ID = "" + Server.Deezer.Enabled = false + Server.ListenBrainz.Enabled = false + Server.Agents = "" + if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL { + Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline + } +} + +func validatePlaylistsPath() error { + for _, path := range strings.Split(Server.PlaylistsPath, string(filepath.ListSeparator)) { + _, err := doublestar.Match(path, "") + if err != nil { + log.Error("Invalid PlaylistsPath", "path", path, err) + return err + } + } + return nil +} + +func validatePurgeMissingOption() error { + allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull} + valid := false + for _, v := range allowedValues { + if v == Server.Scanner.PurgeMissing { + valid = true + break + } + } + if !valid { + err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues) + log.Error(err.Error()) + Server.Scanner.PurgeMissing = consts.PurgeMissingNever + return err + } + return nil +} + +func validateScanSchedule() error { + if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" { + Server.Scanner.Schedule = "" + return nil + } + var err error + Server.Scanner.Schedule, err = validateSchedule(Server.Scanner.Schedule, "Scanner.Schedule") + return err +} + +func validateBackupSchedule() error { + if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { + Server.Backup.Schedule = "" + return nil + } + var err error + Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "Backup.Schedule") + return err +} + +func validateSchedule(schedule, field string) (string, error) { + if _, err := time.ParseDuration(schedule); err == nil { + schedule = "@every " + schedule + } + c := cron.New() + id, err := c.AddFunc(schedule, func() {}) + if err != nil { + log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err) + } else { + c.Remove(id) + } + return schedule, err +} + +// AddHook is used to register initialization code that should run as soon as the config is loaded +func AddHook(hook func()) { + hooks = append(hooks, hook) +} + +func setViperDefaults() { + 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("sessiontimeout", consts.DefaultSessionTimeout) + viper.SetDefault("baseurl", "") + viper.SetDefault("tlscert", "") + viper.SetDefault("tlskey", "") + viper.SetDefault("uiloginbackgroundurl", consts.DefaultUILoginBackgroundURL) + viper.SetDefault("uiwelcomemessage", "") + viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) + viper.SetDefault("enabletranscodingconfig", false) + viper.SetDefault("enabletranscodingcancellation", 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", 5*time.Second) + viper.SetDefault("enabledownloads", true) + viper.SetDefault("enableexternalservices", true) + viper.SetDefault("enablemediafilecoverart", true) + viper.SetDefault("autotranscodedownload", false) + viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat) + viper.SetDefault("searchfullstring", false) + viper.SetDefault("recentlyaddedbymodtime", false) + viper.SetDefault("prefersorttags", false) + viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A") + viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)") + viper.SetDefault("ffmpegpath", "") + viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s") + viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external") + viper.SetDefault("coverjpegquality", 75) + viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external") + viper.SetDefault("lyricspriority", ".lrc,.txt,embedded") + viper.SetDefault("enablegravatar", false) + viper.SetDefault("enablefavourites", true) + viper.SetDefault("enablestarrating", true) + viper.SetDefault("enableuserediting", true) + viper.SetDefault("defaulttheme", "Dark") + viper.SetDefault("defaultlanguage", "") + viper.SetDefault("defaultuivolume", consts.DefaultUIVolume) + viper.SetDefault("enablereplaygain", true) + viper.SetDefault("enablecoveranimation", true) + viper.SetDefault("enablenowplaying", true) + viper.SetDefault("enablesharing", false) + viper.SetDefault("shareurl", "") + viper.SetDefault("defaultshareexpiration", 8760*time.Hour) + viper.SetDefault("defaultdownloadableshare", false) + viper.SetDefault("gatrackingid", "") + viper.SetDefault("enableinsightscollector", true) + viper.SetDefault("enablelogredacting", true) + viper.SetDefault("authrequestlimit", 5) + viper.SetDefault("authwindowlength", 20*time.Second) + viper.SetDefault("passwordencryptionkey", "") + viper.SetDefault("extauth.userheader", "Remote-User") + viper.SetDefault("extauth.trustedsources", "") + 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("scanner.extractor", consts.DefaultScannerExtractor) + viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait) + viper.SetDefault("scanner.scanonstartup", true) + viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner) + viper.SetDefault("scanner.genreseparators", "") + viper.SetDefault("scanner.groupalbumreleases", false) + viper.SetDefault("scanner.followsymlinks", true) + viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever) + viper.SetDefault("subsonic.appendsubtitle", true) + viper.SetDefault("subsonic.artistparticipations", false) + viper.SetDefault("subsonic.defaultreportrealpath", false) + viper.SetDefault("subsonic.legacyclients", "DSub") + viper.SetDefault("agents", "lastfm,spotify,deezer") + viper.SetDefault("lastfm.enabled", true) + viper.SetDefault("lastfm.language", "en") + viper.SetDefault("lastfm.apikey", "") + viper.SetDefault("lastfm.secret", "") + viper.SetDefault("lastfm.scrobblefirstartistonly", false) + viper.SetDefault("spotify.id", "") + viper.SetDefault("spotify.secret", "") + viper.SetDefault("deezer.enabled", true) + viper.SetDefault("deezer.language", "en") + viper.SetDefault("listenbrainz.enabled", true) + viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/") + viper.SetDefault("enablescrobblehistory", true) + viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "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", false) + viper.SetDefault("plugins.cachesize", "100MB") + + viper.SetDefault("meilisearch.enabled", false) + viper.SetDefault("meilisearch.host", "http://localhost:7700") + viper.SetDefault("meilisearch.apikey", "") + + // DevFlags. These are used to enable/disable debugging and incomplete features + viper.SetDefault("devlogsourceline", false) + viper.SetDefault("devenableprofiler", false) + viper.SetDefault("devautocreateadminpassword", "") + viper.SetDefault("devautologinusername", "") + viper.SetDefault("devactivitypanel", true) + viper.SetDefault("devactivitypanelupdaterate", 300*time.Millisecond) + viper.SetDefault("devsidebarplaylists", true) + viper.SetDefault("devshowartistpage", true) + viper.SetDefault("devuishowconfig", true) + viper.SetDefault("devneweventstream", true) + viper.SetDefault("devoffsetoptimize", 50000) + viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/3)) + viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) + viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) + 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("devoptimizedb", true) + viper.SetDefault("devpreserveunicodeinexternalcalls", false) +} + +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)) + + cfgFile = getConfigFile(cfgFile) + if cfgFile != "" { + // Use config file from the flag. + viper.SetConfigFile(cfgFile) + } else { + // Search config in local directory with name "navidrome" (without extension). + viper.AddConfigPath(".") + viper.SetConfigName("navidrome") + } + + _ = viper.BindEnv("port") + if loadEnvVars { + viper.SetEnvPrefix("ND") + replacer := strings.NewReplacer(".", "_") + viper.SetEnvKeyReplacer(replacer) + viper.AutomaticEnv() + } + + err := viper.ReadInConfig() + if viper.ConfigFileUsed() != "" && err != nil { + _, _ = 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 { + return cfgFile + } + } + return "" +} diff --git a/conf/configuration_test.go b/conf/configuration_test.go new file mode 100644 index 0000000..0697345 --- /dev/null +++ b/conf/configuration_test.go @@ -0,0 +1,55 @@ +package conf_test + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/navidrome/navidrome/conf" + . "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() + }) + + 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"), + ) +}) diff --git a/conf/export_test.go b/conf/export_test.go new file mode 100644 index 0000000..1b6daf0 --- /dev/null +++ b/conf/export_test.go @@ -0,0 +1,7 @@ +package conf + +func ResetConf() { + Server = &configOptions{} +} + +var SetViperDefaults = setViperDefaults diff --git a/conf/mime/mime_types.go b/conf/mime/mime_types.go new file mode 100644 index 0000000..33542cb --- /dev/null +++ b/conf/mime/mime_types.go @@ -0,0 +1,48 @@ +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 new file mode 100644 index 0000000..cc8b2a4 --- /dev/null +++ b/conf/testdata/cfg.ini @@ -0,0 +1,8 @@ +[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 new file mode 100644 index 0000000..28fb039 --- /dev/null +++ b/conf/testdata/cfg.json @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000..589e2a1 --- /dev/null +++ b/conf/testdata/cfg.toml @@ -0,0 +1,8 @@ +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 new file mode 100644 index 0000000..e44d2eb --- /dev/null +++ b/conf/testdata/cfg.yaml @@ -0,0 +1,10 @@ +musicFolder: "/yaml/music" +uiWelcomeMessage: "Welcome yaml" +reverseProxyUserHeader: "X-Auth-User" +Tags: + artist: + split: [";"] + custom: + aliases: + - yaml + - test diff --git a/consts/consts.go b/consts/consts.go new file mode 100644 index 0000000..fbb2c94 --- /dev/null +++ b/consts/consts.go @@ -0,0 +1,176 @@ +package consts + +import ( + "os" + "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" + LastScanErrorKey = "LastScanError" + LastScanTypeKey = "LastScanType" + LastScanStartTimeKey = "LastScanStartTime" + + UIAuthorizationHeader = "X-ND-Authorization" + UIClientUniqueIDHeader = "X-ND-Client-Unique-Id" + JWTSecretKey = "JWTSecret" + JWTIssuer = "ND" + DefaultSessionTimeout = 48 * time.Hour + CookieExpiry = 365 * 24 * 3600 // One year + + OptimizeDBSchedule = "@every 24h" + + // 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 + + DevInitialUserName = "admin" + DevInitialName = "Dev Admin" + + URLPathUI = "/app" + URLPathNativeAPI = "/api" + URLPathSubsonicAPI = "/rest" + URLPathPublic = "/share" + URLPathPublicImages = URLPathPublic + "/img" + + // DefaultUILoginBackgroundURL uses Navidrome curated background images collection, + // available at https://unsplash.com/collections/20072696/navidrome + DefaultUILoginBackgroundURL = "/backgrounds" + + // DefaultUILoginBackgroundOffline Background image used in case external integrations are disabled + DefaultUILoginBackgroundOffline = "iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAIAAAAiOjnJAAAABGdBTUEAALGPC/xhBQAAAiJJREFUeF7t0IEAAAAAw6D5Ux/khVBhwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDBgwIABAwYMGDDwMDDVlwABBWcSrQAAAABJRU5ErkJggg==" + DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline + DefaultMaxSidebarPlaylists = 100 + + RequestThrottleBacklogLimit = 100 + RequestThrottleBacklogTimeout = time.Minute + + ServerReadHeaderTimeout = 3 * time.Second + + ArtistInfoTimeToLive = 24 * time.Hour + AlbumInfoTimeToLive = 7 * 24 * time.Hour + UpdateLastAccessFrequency = time.Minute + UpdatePlayerFrequency = time.Minute + + I18nFolder = "i18n" + ScanIgnoreFile = ".ndignore" + + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + UICoverArtSize = 300 + DefaultUIVolume = 100 + + DefaultHttpClientTimeOut = 10 * time.Second + + DefaultScannerExtractor = "taglib" + DefaultWatcherWait = 5 * time.Second + Zwsp = string('\u200b') +) + +// Prometheus options +const ( + PrometheusDefaultPath = "/metrics" + PrometheusAuthUser = "navidrome" +) + +// Cache options +const ( + TranscodingCacheDir = "transcoding" + DefaultTranscodingCacheMaxItems = 0 // Unlimited + + ImageCacheDir = "images" + DefaultImageCacheMaxItems = 0 // Unlimited + + DefaultCacheSize = 100 * 1024 * 1024 // 100MB + DefaultCacheCleanUpInterval = 10 * time.Minute +) + +const ( + AlbumPlayCountModeAbsolute = "absolute" + AlbumPlayCountModeNormalized = "normalized" +) + +const ( + //DefaultAlbumPID = "album_legacy" + DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate" + DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title" + PIDAlbumKey = "PIDAlbum" + PIDTrackKey = "PIDTrack" +) + +const ( + InsightsIDKey = "InsightsID" + InsightsEndpoint = "https://insights.navidrome.org/collect" + InsightsUpdateInterval = 24 * time.Hour + InsightsInitialDelay = 30 * time.Minute +) + +const ( + PurgeMissingNever = "never" + PurgeMissingAlways = "always" + PurgeMissingFull = "full" +) + +var ( + DefaultDownsamplingFormat = "opus" + DefaultTranscodings = []struct { + Name string + TargetFormat string + DefaultBitRate int + Command string + }{ + { + Name: "mp3 audio", + TargetFormat: "mp3", + DefaultBitRate: 192, + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + Name: "opus audio", + TargetFormat: "opus", + DefaultBitRate: 128, + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + Name: "aac audio", + TargetFormat: "aac", + DefaultBitRate: 256, + Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + } +) + +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)) + 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/version.go b/consts/version.go new file mode 100644 index 0000000..cde2449 --- /dev/null +++ b/consts/version.go @@ -0,0 +1,26 @@ +package consts + +import ( + "fmt" + "strings" +) + +var ( + // This will be set in build time. If not, version will be set to "dev" + gitTag string + gitSha string +) + +// Version holds the version string, with tag and git sha info. +// Examples: +// dev +// v0.2.0 (5b84188) +// v0.3.2-SNAPSHOT (715f552) +// master (9ed35cb) +var Version = func() string { + if gitSha == "" { + return "dev" + } + gitTag = strings.TrimPrefix(gitTag, "v") + return fmt.Sprintf("%s (%s)", gitTag, gitSha) +}() diff --git a/contrib/docker-compose/Caddyfile b/contrib/docker-compose/Caddyfile new file mode 100644 index 0000000..8bea044 --- /dev/null +++ b/contrib/docker-compose/Caddyfile @@ -0,0 +1,7 @@ +https://your.website { + reverse_proxy * navidrome:4533 { + header_up Host {http.reverse_proxy.upstream.hostport} + header_up X-Forwarded-For {http.request.remote} + header_up X-Real-IP {http.reverse_proxy.upstream.port} + } +} \ No newline at end of file diff --git a/contrib/docker-compose/docker-compose-caddy.yml b/contrib/docker-compose/docker-compose-caddy.yml new file mode 100644 index 0000000..d259c0e --- /dev/null +++ b/contrib/docker-compose/docker-compose-caddy.yml @@ -0,0 +1,31 @@ +version: '3.6' + +volumes: + caddy_data: + navidrome_data: + +services: + + caddy: + container_name: "caddy" + image: caddy:2.6-alpine + restart: unless-stopped + read_only: true + volumes: + - "caddy_data:/data:rw" + - "./Caddyfile:/etc/caddy/Caddyfile:ro" + ports: + - "80:80" + - "443:443" + + navidrome: + container_name: "navidrome" + image: deluan/navidrome:latest + restart: unless-stopped + read_only: true + # user: 1000:1000 + ports: + - "4533:4533" + volumes: + - "navidrome_data:/data" + #- "/mnt/music:/music:ro" diff --git a/contrib/docker-compose/docker-compose-traefik.yml b/contrib/docker-compose/docker-compose-traefik.yml new file mode 100644 index 0000000..825d7ba --- /dev/null +++ b/contrib/docker-compose/docker-compose-traefik.yml @@ -0,0 +1,51 @@ +version: "3.6" + +volumes: + traefik_data: + navidrome_data: + +services: + + traefik: + container_name: "traefik" + image: traefik:2.9 + restart: unless-stopped + read_only: true + command: + - "--log.level=ERROR" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--entrypoints.websecure.address=:443" + - "--certificatesresolvers.tc.acme.tlschallenge=true" + #- "--certificatesresolvers.tc.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" + - "--certificatesresolvers.tc.acme.email=foo@foo.com" + - "--certificatesresolvers.tc.acme.storage=/letsencrypt/acme.json" + ports: + - "443:443" + volumes: + - "traefik_data:/letsencrypt" + #- "/var/run/docker.sock:/var/run/docker.sock:ro" + + navidrome: + container_name: "navidrome" + image: deluan/navidrome:latest + restart: unless-stopped + read_only: true + # user: 1000:1000 + ports: + - "4533:4533" + environment: + ND_SCANINTERVAL: 6h + ND_LOGLEVEL: info + ND_SESSIONTIMEOUT: 168h + ND_BASEURL: "" + volumes: + - "navidrome_data:/data" + #- "/mnt/music:/music:ro" + labels: + - "traefik.enable=true" + - "traefik.http.routers.navidrome.rule=Host(`foo.com`)" + - "traefik.http.routers.navidrome.entrypoints=websecure" + - "traefik.http.routers.navidrome.tls=true" + - "traefik.http.routers.navidrome.tls.certresolver=tc" + - "traefik.http.services.navidrome.loadbalancer.server.port=4533" diff --git a/contrib/freebsd_rc b/contrib/freebsd_rc new file mode 100644 index 0000000..55f9470 --- /dev/null +++ b/contrib/freebsd_rc @@ -0,0 +1,52 @@ +#!/bin/sh +# +# $FreeBSD: $ +# +# PROVIDE: navidrome +# REQUIRE: NETWORKING +# KEYWORD: +# +# Add the following lines to /etc/rc.conf to enable navidrome: +# navidrome_enable="YES" +# +# navidrome_enable (bool): Set to YES to enable navidrome +# Default: NO +# navidrome_config (str): navidrome configuration file +# Default: /usr/local/etc/navidrome/config.toml +# navidrome_datafolder (str): navidrome Folder to store application data +# Default: www +# navidrome_user (str): navidrome daemon user +# Default: www +# navidrome_group (str): navidrome daemon group +# Default: www + +. /etc/rc.subr + +name="navidrome" +rcvar="navidrome_enable" +load_rc_config $name + +: ${navidrome_user:="www"} +: ${navidrome_group:="www"} +: ${navidrome_enable:="NO"} +: ${navidrome_config:="/usr/local/etc/navidrome/config.toml"} +: ${navidrome_flags=""} +: ${navidrome_facility:="daemon"} +: ${navidrome_priority:="debug"} +: ${navidrome_datafolder:="/var/db/${name}"} + +required_dirs=${navidrome_datafolder} +required_files=${navidrome_config} +procname="/usr/local/bin/${name}" +pidfile="/var/run/${name}.pid" +start_precmd="${name}_precmd" +command=/usr/sbin/daemon +command_args="-S -l ${navidrome_facility} -s ${navidrome_priority} -T ${name} -t ${name} -p ${pidfile} \ + ${procname} --configfile ${navidrome_config} --datafolder ${navidrome_datafolder} ${navidrome_flags}" + +navidrome_precmd() +{ + install -o ${navidrome_user} /dev/null ${pidfile} +} + +run_rc_command "$1" diff --git a/contrib/k8s/README.md b/contrib/k8s/README.md new file mode 100644 index 0000000..8ec5270 --- /dev/null +++ b/contrib/k8s/README.md @@ -0,0 +1,11 @@ +# Kubernetes + +A couple things to keep in mind with this manifest: + +1. This creates a namespace called `navidrome`. Adjust this as needed. +1. This manifest was created on [K3s](https://github.com/k3s-io/k3s), which uses its own storage provisioner called [local-path-provisioner](https://github.com/rancher/local-path-provisioner). Be sure to change the `storageClassName` of the `PersistentVolumeClaim` as needed. +1. The `PersistentVolumeClaim` sets up a 2Gi volume for Navidrome's database. Adjust this as needed. +1. Be sure to change the `image` tag from `ghcr.io/navidrome/navidrome:0.49.3` to whatever the newest version is. +1. This assumes your music is mounted on the host using `hostPath` at `/path/to/your/music/on/the/host`. Adjust this as needed. +1. The `Ingress` is already configured for `cert-manager` to obtain a Let's Encrypt TLS certificate and uses Traefik for routing. Adjust this as needed. +1. The `Ingress` presents the service at `navidrome.${SECRET_INTERNAL_DOMAIN_NAME}`, which needs to already be setup in DNS. diff --git a/contrib/k8s/manifest.yml b/contrib/k8s/manifest.yml new file mode 100644 index 0000000..fe96361 --- /dev/null +++ b/contrib/k8s/manifest.yml @@ -0,0 +1,111 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: navidrome +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: navidrome-data-pvc + namespace: navidrome + annotations: + volumeType: local +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + storageClassName: local-path +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: navidrome-deployment + namespace: navidrome +spec: + replicas: 1 + revisionHistoryLimit: 2 + selector: + matchLabels: + app: navidrome + template: + metadata: + labels: + app: navidrome + spec: + containers: + - name: navidrome + image: ghcr.io/navidrome/navidrome:0.49.3 + ports: + - containerPort: 4533 + env: + - name: ND_SCANSCHEDULE + value: "12h" + - name: ND_SESSIONTIMEOUT + value: "24h" + - name: ND_LOGLEVEL + value: "info" + - name: ND_ENABLETRANSCODINGCONFIG + value: "false" + - name: ND_TRANSCODINGCACHESIZE + value: "512MB" + - name: ND_ENABLESTARRATING + value: "false" + - name: ND_ENABLEFAVOURITES + value: "false" + volumeMounts: + - name: data + mountPath: /data + - name: music + mountPath: /music + readOnly: true + volumes: + - name: data + persistentVolumeClaim: + claimName: navidrome-data-pvc + - name: music + hostPath: + path: /path/to/your/music/on/the/host + type: Directory +--- +apiVersion: v1 +kind: Service +metadata: + name: navidrome-service + namespace: navidrome +spec: + type: ClusterIP + ports: + - name: http + targetPort: 4533 + port: 4533 + protocol: TCP + selector: + app: navidrome +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: navidrome-ingress + namespace: navidrome + annotations: + cert-manager.io/cluster-issuer: letsencrypt-production + traefik.ingress.kubernetes.io/router.tls: "true" +spec: + rules: + - host: navidrome.${SECRET_INTERNAL_DOMAIN_NAME} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: navidrome-service + port: + number: 4533 + tls: + - hosts: + - navidrome.${SECRET_INTERNAL_DOMAIN_NAME} + secretName: navidrome-tls diff --git a/contrib/navidrome b/contrib/navidrome new file mode 100644 index 0000000..bb69087 --- /dev/null +++ b/contrib/navidrome @@ -0,0 +1,15 @@ +#!/sbin/openrc-run + +name=$RC_SVCNAME +command="/opt/navidrome/${RC_SVCNAME}" +command_args="-datafolder /opt/navidrome" +command_user="${RC_SVCNAME}" +pidfile="/var/run/${RC_SVCNAME}.pid" +output_log="/opt/navidrome/${RC_SVCNAME}.log" +error_log="/opt/navidrome/${RC_SVCNAME}.err" +command_background="yes" + +depend() { + need net +} + diff --git a/contrib/navidrome.service b/contrib/navidrome.service new file mode 100644 index 0000000..5e6cbed --- /dev/null +++ b/contrib/navidrome.service @@ -0,0 +1,54 @@ +# This file ususaly goes in /etc/systemd/system + +[Unit] +Description=Navidrome Music Server and Streamer compatible with Subsonic/Airsonic +After=remote-fs.target network.target + +[Install] +WantedBy=multi-user.target + +[Service] +User=navidrome +Group=navidrome +Type=simple +ExecStart=/usr/bin/navidrome --configfile "/etc/navidrome/navidrome.toml" +StateDirectory=navidrome +WorkingDirectory=/var/lib/navidrome +TimeoutStopSec=20 +KillMode=process +Restart=on-failure + +# See https://www.freedesktop.org/software/systemd/man/systemd.exec.html +CapabilityBoundingSet= +DevicePolicy=closed +NoNewPrivileges=yes +LockPersonality=yes +PrivateTmp=yes +PrivateUsers=yes +ProtectControlGroups=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectClock=yes +ProtectHostname=yes +ProtectKernelLogs=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=yes +RestrictRealtime=yes +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +SystemCallFilter=setrlimit +SystemCallArchitectures=native +UMask=0066 + +# You can uncomment the following line if you're not using the jukebox This +# will prevent navidrome from accessing any real (physical) devices +#PrivateDevices=yes + +# You can change the following line to `strict` instead of `full` if you don't +# want navidrome to be able to write anything on your filesystem outside of +# /var/lib/navidrome. +ProtectSystem=full + +# You can comment the following line if you don't have any media in /home/*. +# This will prevent navidrome from ever reading/writing anything there. +ProtectHome=true diff --git a/core/agents/README.md b/core/agents/README.md new file mode 100644 index 0000000..1a3a8e9 --- /dev/null +++ b/core/agents/README.md @@ -0,0 +1,12 @@ +This folder abstracts metadata lookup into "agents". Each agent can be implemented to get as +much info as the external source provides, by using a granular set of interfaces +(see [interfaces](interfaces.go)). + +A new agent must comply with these simple implementation rules: +1) Implement the `AgentName()` method. It just returns the name of the agent for logging purposes. +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 `"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 new file mode 100644 index 0000000..cb10d2c --- /dev/null +++ b/core/agents/agents.go @@ -0,0 +1,374 @@ +package agents + +import ( + "context" + "slices" + "strings" + "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/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) +} + +type Agents struct { + ds model.DataStore + pluginLoader PluginLoader +} + +// 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, + } +} + +// 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") + } + + 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) + } + } + return validAgents +} + +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) + } + } + + return nil +} + +func (a *Agents) AgentName() string { + return "agents" +} + +func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { + switch id { + case consts.UnknownArtistID: + return "", ErrNotFound + case consts.VariousArtistsID: + return "", nil + } + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(ArtistMBIDRetriever) + if !ok { + continue + } + mbid, err := retriever.GetArtistMBID(ctx, id, name) + if mbid != "" && err == nil { + log.Debug(ctx, "Got MBID", "agent", ag.AgentName(), "artist", name, "mbid", mbid, "elapsed", time.Since(start)) + return mbid, nil + } + } + return "", ErrNotFound +} + +func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { + switch id { + case consts.UnknownArtistID: + return "", ErrNotFound + case consts.VariousArtistsID: + return "", nil + } + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(ArtistURLRetriever) + if !ok { + continue + } + url, err := retriever.GetArtistURL(ctx, id, name, mbid) + if url != "" && err == nil { + log.Debug(ctx, "Got External Url", "agent", ag.AgentName(), "artist", name, "url", url, "elapsed", time.Since(start)) + return url, nil + } + } + return "", ErrNotFound +} + +func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { + switch id { + case consts.UnknownArtistID: + return "", ErrNotFound + case consts.VariousArtistsID: + return "", nil + } + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(ArtistBiographyRetriever) + if !ok { + continue + } + bio, err := retriever.GetArtistBiography(ctx, id, name, mbid) + if err == nil { + log.Debug(ctx, "Got Biography", "agent", ag.AgentName(), "artist", name, "len", len(bio), "elapsed", time.Since(start)) + return bio, nil + } + } + return "", ErrNotFound +} + +// 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: + return nil, ErrNotFound + case consts.VariousArtistsID: + return nil, nil + } + + overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier) + + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(ArtistSimilarRetriever) + if !ok { + continue + } + similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit) + if len(similar) > 0 && err == nil { + if log.IsGreaterOrEqualTo(log.LevelTrace) { + log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start)) + } else { + log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similarReceived", len(similar), "elapsed", time.Since(start)) + } + return similar, err + } + } + return nil, ErrNotFound +} + +func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) { + switch id { + case consts.UnknownArtistID: + return nil, ErrNotFound + case consts.VariousArtistsID: + return nil, nil + } + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(ArtistImageRetriever) + if !ok { + continue + } + images, err := retriever.GetArtistImages(ctx, id, name, mbid) + if len(images) > 0 && err == nil { + log.Debug(ctx, "Got Images", "agent", ag.AgentName(), "artist", name, "images", images, "elapsed", time.Since(start)) + return images, nil + } + } + return nil, ErrNotFound +} + +// 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: + return nil, ErrNotFound + case consts.VariousArtistsID: + return nil, nil + } + + overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier) + + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(ArtistTopSongsRetriever) + if !ok { + continue + } + songs, err := retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit) + if len(songs) > 0 && err == nil { + log.Debug(ctx, "Got Top Songs", "agent", ag.AgentName(), "artist", artistName, "songs", songs, "elapsed", time.Since(start)) + return songs, nil + } + } + return nil, ErrNotFound +} + +func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) { + if name == consts.UnknownAlbum { + return nil, ErrNotFound + } + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(AlbumInfoRetriever) + if !ok { + continue + } + album, err := retriever.GetAlbumInfo(ctx, name, artist, mbid) + if err == nil { + log.Debug(ctx, "Got Album Info", "agent", ag.AgentName(), "album", name, "artist", artist, + "mbid", mbid, "elapsed", time.Since(start)) + return album, nil + } + } + return nil, ErrNotFound +} + +func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]ExternalImage, error) { + if name == consts.UnknownAlbum { + return nil, ErrNotFound + } + start := time.Now() + for _, enabledAgent := range a.getEnabledAgentNames() { + ag := a.getAgent(enabledAgent) + if ag == nil { + continue + } + if utils.IsCtxDone(ctx) { + break + } + retriever, ok := ag.(AlbumImageRetriever) + if !ok { + continue + } + images, err := retriever.GetAlbumImages(ctx, name, artist, mbid) + if len(images) > 0 && err == nil { + log.Debug(ctx, "Got Album Images", "agent", ag.AgentName(), "album", name, "artist", artist, + "mbid", mbid, "elapsed", time.Since(start)) + return images, nil + } + } + return nil, ErrNotFound +} + +var _ Interface = (*Agents)(nil) +var _ ArtistMBIDRetriever = (*Agents)(nil) +var _ ArtistURLRetriever = (*Agents)(nil) +var _ ArtistBiographyRetriever = (*Agents)(nil) +var _ ArtistSimilarRetriever = (*Agents)(nil) +var _ ArtistImageRetriever = (*Agents)(nil) +var _ ArtistTopSongsRetriever = (*Agents)(nil) +var _ AlbumInfoRetriever = (*Agents)(nil) +var _ AlbumImageRetriever = (*Agents)(nil) diff --git a/core/agents/agents_plugin_test.go b/core/agents/agents_plugin_test.go new file mode 100644 index 0000000..b2791c0 --- /dev/null +++ b/core/agents/agents_plugin_test.go @@ -0,0 +1,281 @@ +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_suite_test.go b/core/agents/agents_suite_test.go new file mode 100644 index 0000000..54b3f09 --- /dev/null +++ b/core/agents/agents_suite_test.go @@ -0,0 +1,17 @@ +package agents + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAgents(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Agents Test Suite") +} diff --git a/core/agents/agents_test.go b/core/agents/agents_test.go new file mode 100644 index 0000000..0b7eec2 --- /dev/null +++ b/core/agents/agents_test.go @@ -0,0 +1,400 @@ +package agents + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +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} + }) + + Describe("Local", func() { + var ag *Agents + BeforeEach(func() { + conf.Server.Agents = "" + ag = createAgents(ds, nil) + }) + + It("calls the placeholder GetArtistImages", func() { + 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{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}})) + }) + }) + + Describe("Agents", func() { + var ag *Agents + 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) + 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("GetArtistMBID", func() { + It("returns on first match", func() { + Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid")) + Expect(mock.Args).To(HaveExactElements("123", "test")) + }) + It("returns empty if artist is Various Artists", func() { + mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists) + Expect(err).ToNot(HaveOccurred()) + Expect(mbid).To(BeEmpty()) + Expect(mock.Args).To(BeEmpty()) + }) + It("returns not found if artist is Unknown Artist", func() { + mbid, err := ag.GetArtistMBID(ctx, consts.VariousArtistsID, consts.VariousArtists) + Expect(err).ToNot(HaveOccurred()) + Expect(mbid).To(BeEmpty()) + Expect(mock.Args).To(BeEmpty()) + }) + It("skips the agent if it returns an error", func() { + mock.Err = errors.New("error") + _, err := ag.GetArtistMBID(ctx, "123", "test") + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(HaveExactElements("123", "test")) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetArtistMBID(ctx, "123", "test") + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) + + 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")) + }) + It("returns empty if artist is Various Artists", func() { + url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "") + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(BeEmpty()) + Expect(mock.Args).To(BeEmpty()) + }) + It("returns not found if artist is Unknown Artist", func() { + url, err := ag.GetArtistURL(ctx, consts.VariousArtistsID, consts.VariousArtists, "") + Expect(err).ToNot(HaveOccurred()) + Expect(url).To(BeEmpty()) + Expect(mock.Args).To(BeEmpty()) + }) + It("skips the agent if it returns an error", 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")) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetArtistURL(ctx, "123", "test", "mb123") + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) + + 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")) + }) + It("returns empty if artist is Various Artists", func() { + bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "") + Expect(err).ToNot(HaveOccurred()) + Expect(bio).To(BeEmpty()) + Expect(mock.Args).To(BeEmpty()) + }) + It("returns not found if artist is Unknown Artist", func() { + bio, err := ag.GetArtistBiography(ctx, consts.VariousArtistsID, consts.VariousArtists, "") + Expect(err).ToNot(HaveOccurred()) + Expect(bio).To(BeEmpty()) + Expect(mock.Args).To(BeEmpty()) + }) + It("skips the agent if it returns an error", 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")) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetArtistBiography(ctx, "123", "test", "mb123") + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) + + 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")) + }) + 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")) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetArtistImages(ctx, "123", "test", "mb123") + 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() { + It("returns on first match", func() { + Expect(ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1)).To(Equal([]Artist{{ + Name: "Joe Dohn", + MBID: "mbid321", + }})) + Expect(mock.Args).To(HaveExactElements("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)) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 1) + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) + + 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)) + }) + 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)) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2) + 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() { + It("returns meaningful data", func() { + Expect(ag.GetAlbumInfo(ctx, "album", "artist", "mbid")).To(Equal(&AlbumInfo{ + Name: "A Song", + MBID: "mbid444", + Description: "A Description", + URL: "External URL", + })) + Expect(mock.Args).To(HaveExactElements("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")) + }) + It("interrupts if the context is canceled", func() { + cancel() + _, err := ag.GetAlbumInfo(ctx, "album", "artist", "mbid") + Expect(err).To(MatchError(ErrNotFound)) + Expect(mock.Args).To(BeEmpty()) + }) + }) + }) +}) + +type mockAgent struct { + Args []interface{} + Err error +} + +func (a *mockAgent) AgentName() string { + return "fake" +} + +func (a *mockAgent) GetArtistMBID(_ context.Context, id string, name string) (string, error) { + a.Args = []interface{}{id, name} + if a.Err != nil { + return "", a.Err + } + return "mbid", nil +} + +func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (string, error) { + a.Args = []interface{}{id, name, mbid} + if a.Err != nil { + return "", a.Err + } + return "url", nil +} + +func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) { + a.Args = []interface{}{id, name, mbid} + if a.Err != nil { + return "", a.Err + } + return "bio", nil +} + +func (a *mockAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { + a.Args = []interface{}{id, name, mbid} + if a.Err != nil { + return nil, a.Err + } + return []ExternalImage{{ + URL: "imageUrl", + Size: 100, + }}, nil +} + +func (a *mockAgent) GetSimilarArtists(_ context.Context, id, name, mbid string, limit int) ([]Artist, error) { + a.Args = []interface{}{id, name, mbid, limit} + if a.Err != nil { + return nil, a.Err + } + return []Artist{{ + Name: "Joe Dohn", + MBID: "mbid321", + }}, nil +} + +func (a *mockAgent) GetArtistTopSongs(_ context.Context, id, artistName, mbid string, count int) ([]Song, error) { + a.Args = []interface{}{id, artistName, mbid, count} + if a.Err != nil { + return nil, a.Err + } + return []Song{{ + Name: "A Song", + MBID: "mbid444", + }}, nil +} + +func (a *mockAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*AlbumInfo, error) { + a.Args = []interface{}{name, artist, mbid} + if a.Err != nil { + return nil, a.Err + } + return &AlbumInfo{ + Name: "A Song", + MBID: "mbid444", + Description: "A Description", + URL: "External URL", + }, nil +} + +type emptyAgent struct { + Interface +} + +func (e *emptyAgent) AgentName() string { + return "empty" +} + +type testImageAgent struct { + Name string + Images []ExternalImage + Err error + Args []interface{} +} + +func (t *testImageAgent) AgentName() string { return t.Name } + +func (t *testImageAgent) GetArtistImages(_ context.Context, id, name, mbid string) ([]ExternalImage, error) { + t.Args = []interface{}{id, name, mbid} + return t.Images, t.Err +} diff --git a/core/agents/deezer/client.go b/core/agents/deezer/client.go new file mode 100644 index 0000000..32d93ba --- /dev/null +++ b/core/agents/deezer/client.go @@ -0,0 +1,218 @@ +package deezer + +import ( + bytes "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/microcosm-cc/bluemonday" + "github.com/navidrome/navidrome/log" +) + +const apiBaseURL = "https://api.deezer.com" +const authBaseURL = "https://auth.deezer.com" + +var ( + ErrNotFound = errors.New("deezer: not found") +) + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +type client struct { + httpDoer httpDoer + language string + jwt jwtToken +} + +func newClient(hc httpDoer, language string) *client { + return &client{ + httpDoer: hc, + language: language, + } +} + +func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) { + params := url.Values{} + params.Add("q", name) + 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, 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 + } + + if resp.StatusCode != 200 { + return c.parseError(data) + } + + return json.Unmarshal(data, response) +} + +func (c *client) parseError(data []byte) error { + var deezerError Error + err := json.Unmarshal(data, &deezerError) + if err != nil { + return err + } + return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message) +} + +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) (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", c.language) + req.Header.Set("Authorization", "Bearer "+jwt) + + log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language) + 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/core/agents/deezer/client_auth.go b/core/agents/deezer/client_auth.go new file mode 100644 index 0000000..c88c2bc --- /dev/null +++ b/core/agents/deezer/client_auth.go @@ -0,0 +1,101 @@ +package deezer + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/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"` + } + + 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 := token.Expiration() + if 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/core/agents/deezer/client_auth_test.go b/core/agents/deezer/client_auth_test.go new file mode 100644 index 0000000..b0c2d19 --- /dev/null +++ b/core/agents/deezer/client_auth_test.go @@ -0,0 +1,293 @@ +package deezer + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "sync" + "time" + + "github.com/lestrrat-go/jwx/v2/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, "en") + 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 + Expect(testToken.Expiration().IsZero()).To(BeTrue()) + + 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 := 0; i < 100; i++ { + cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour) + time.Sleep(1 * time.Millisecond) + } + }) + + // Reader goroutine + wg.Go(func() { + for i := 0; i < 100; i++ { + 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/core/agents/deezer/client_test.go b/core/agents/deezer/client_test.go new file mode 100644 index 0000000..7e4f7a4 --- /dev/null +++ b/core/agents/deezer/client_test.go @@ -0,0 +1,195 @@ +package deezer + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "time" + + . "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, "en") + }) + + 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(ErrNotFound)) + }) + }) + + 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.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) + Expect(err).To(BeNil()) + Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel")) + Expect(bio).ToNot(ContainSubstring("

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

")) + }) + + It("uses the configured language", func() { + client = newClient(httpClient, "fr") + // Mock JWT token for the new client instance with a valid JWT + testJWT := createTestJWT(5 * time.Minute) + httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))), + }) + f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + Expect(err).To(BeNil()) + Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr")) + }) + + It("includes the JWT token in the request", func() { + f, err := os.Open("tests/fixtures/deezer.artist.bio.json") + Expect(err).To(BeNil()) + httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200}) + + _, err = client.getArtistBio(GinkgoT().Context(), 27) + 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) + 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) + 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) + 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) + 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/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go new file mode 100644 index 0000000..8f3e505 --- /dev/null +++ b/core/agents/deezer/deezer.go @@ -0,0 +1,148 @@ +package deezer + +import ( + "context" + "errors" + "net/http" + "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/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 +} + +func deezerConstructor(dataStore model.DataStore) agents.Interface { + agent := &deezerAgent{dataStore: dataStore} + httpClient := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut) + agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language) + 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 != "" { + res = append(res, agents.ExternalImage{ + URL: imgData.URL, + Size: imgData.Size, + }) + } + } + return res, nil +} + +func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) { + artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit) + if errors.Is(err, ErrNotFound) || len(artists) == 0 { + return nil, agents.ErrNotFound + } + if err != nil { + return nil, err + } + + // If the first one has the same name, that's the one + if !strings.EqualFold(artists[0].Name, name) { + return nil, agents.ErrNotFound + } + return &artists[0], err +} + +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, + } + }) + 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 + } + + return s.client.getArtistBio(ctx, artist.ID) +} + +func init() { + conf.AddHook(func() { + if conf.Server.Deezer.Enabled { + agents.Register(deezerAgentName, deezerConstructor) + } + }) +} diff --git a/core/agents/deezer/deezer_suite_test.go b/core/agents/deezer/deezer_suite_test.go new file mode 100644 index 0000000..a42282d --- /dev/null +++ b/core/agents/deezer/deezer_suite_test.go @@ -0,0 +1,17 @@ +package deezer + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDeezer(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Deezer Test Suite") +} diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go new file mode 100644 index 0000000..266c44c --- /dev/null +++ b/core/agents/deezer/responses.go @@ -0,0 +1,66 @@ +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 Error struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + Code int `json:"code"` + } `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/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go new file mode 100644 index 0000000..a9de5c5 --- /dev/null +++ b/core/agents/deezer/responses_test.go @@ -0,0 +1,69 @@ +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 Error + 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/core/agents/interfaces.go b/core/agents/interfaces.go new file mode 100644 index 0000000..e60c619 --- /dev/null +++ b/core/agents/interfaces.go @@ -0,0 +1,84 @@ +package agents + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/model" +) + +type Constructor func(ds model.DataStore) Interface + +type Interface interface { + AgentName() string +} + +// AlbumInfo contains album metadata (no images) +type AlbumInfo struct { + Name string + MBID string + Description string + URL string +} + +type Artist struct { + Name string + MBID string +} + +type ExternalImage struct { + URL string + Size int +} + +type Song struct { + Name string + MBID string +} + +var ( + ErrNotFound = errors.New("not found") +) + +// AlbumInfoRetriever provides album info (no images) +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) +} + +type ArtistURLRetriever interface { + GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) +} + +type ArtistBiographyRetriever interface { + GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) +} + +type ArtistSimilarRetriever interface { + GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) +} + +type ArtistImageRetriever interface { + GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) +} + +type ArtistTopSongsRetriever interface { + GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) +} + +var Map map[string]Constructor + +func Register(name string, init Constructor) { + if Map == nil { + Map = make(map[string]Constructor) + } + Map[name] = init +} diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go new file mode 100644 index 0000000..e3e53b2 --- /dev/null +++ b/core/agents/lastfm/agent.go @@ -0,0 +1,383 @@ +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" + "golang.org/x/net/html" +) + +const ( + lastFMAgentName = "lastfm" + sessionKeyProperty = "LastFMSessionKey" +) + +var ignoredBiographies = []string{ + // Unknown Artist + ` head > meta[property="og:image"]`) + artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name +) + +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) + 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 { + return res, nil + } + 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 +} + +func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string) (*Album, error) { + a, err := l.client.albumGetInfo(ctx, name, artist, mbid) + var lfErr *lastFMError + isLastFMError := errors.As(err, &lfErr) + + if mbid != "" && (isLastFMError && lfErr.Code == 6) { + log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid) + return l.callAlbumGetInfo(ctx, name, artist, "") + } + + if err != nil { + if isLastFMError && lfErr.Code == 6 { + log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err) + } else { + log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err) + } + return nil, err + } + return a, nil +} + +func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string) (*Artist, error) { + l.getInfoMutex.Lock() + defer l.getInfoMutex.Unlock() + + a, err := l.client.artistGetInfo(ctx, name) + if err != nil { + 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) 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) + } + if lfErr.Code == 11 || lfErr.Code == 16 { + 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 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/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go new file mode 100644 index 0000000..fc62384 --- /dev/null +++ b/core/agents/lastfm/agent_test.go @@ -0,0 +1,487 @@ +package lastfm + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "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 language", func() { + conf.Server.LastFM.Language = "pt" + agent := lastFMConstructor(ds) + Expect(agent.apiKey).To(Equal("123")) + Expect(agent.secret).To(Equal("secret")) + Expect(agent.lang).To(Equal("pt")) + }) + }) + 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", "pt", 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. Read more on Last.fm")) + 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")) + }) + }) + + 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", "", 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", "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", "", 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("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, + 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)) + 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.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()) + sentParams := httpClient.SavedRequest.URL.Query() + 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)) + 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.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()) + sentParams := httpClient.SavedRequest.URL.Query() + 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 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", + })) + 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", + })) + 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()) + }) + }) + }) + + 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", "pt", 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("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("returns empty list if 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} + + images, err := agent.GetArtistImages(ctx, "123", "U2", "") + Expect(err).ToNot(HaveOccurred()) + Expect(images).To(BeEmpty()) + }) + + 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")) + }) + }) +}) diff --git a/core/agents/lastfm/auth_router.go b/core/agents/lastfm/auth_router.go new file mode 100644 index 0000000..290caaa --- /dev/null +++ b/core/agents/lastfm/auth_router.go @@ -0,0 +1,132 @@ +package lastfm + +import ( + "bytes" + "context" + _ "embed" + "errors" + "net/http" + "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/consts" + "github.com/navidrome/navidrome/core/agents" + "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/utils/req" +) + +//go:embed token_received.html +var tokenReceivedPage []byte + +type Router struct { + http.Handler + ds model.DataStore + sessionKeys *agents.SessionKeys + client *client + apiKey string + secret string +} + +func NewRouter(ds model.DataStore) *Router { + r := &Router{ + ds: ds, + apiKey: conf.Server.LastFM.ApiKey, + secret: conf.Server.LastFM.Secret, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + r.Handler = r.routes() + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + r.client = newClient(r.apiKey, r.secret, "en", hc) + return r +} + +func (s *Router) routes() http.Handler { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(server.Authenticator(s.ds)) + r.Use(server.JWTRefresher) + + r.Get("/link", s.getLinkStatus) + r.Delete("/link", s.unlink) + }) + + r.Get("/link/callback", s.callback) + + return r +} + +func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "apiKey": s.apiKey, + } + u, _ := request.UserFrom(r.Context()) + key, err := s.sessionKeys.Get(r.Context(), u.ID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + resp["error"] = err + resp["status"] = false + _ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp) + return + } + resp["status"] = key != "" + _ = rest.RespondWithJSON(w, http.StatusOK, resp) +} + +func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + err := s.sessionKeys.Delete(r.Context(), u.ID) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + } else { + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{}) + } +} + +func (s *Router) callback(w http.ResponseWriter, r *http.Request) { + p := req.Params(r) + token, err := p.String("token") + if err != nil { + _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") + return + } + uid, err := p.String("uid") + if err != nil { + _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") + 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) + 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))) + return + } + + http.ServeContent(w, r, "response", time.Now(), bytes.NewReader(tokenReceivedPage)) +} + +func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error { + sessionKey, err := s.client.getSession(ctx, token) + if err != nil { + log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token, + "requestId", middleware.GetReqID(ctx), err) + return err + } + err = s.sessionKeys.Put(ctx, uid, sessionKey) + if err != nil { + log.Error("Could not save LastFM session key", "userId", uid, "requestId", middleware.GetReqID(ctx), err) + } + return err +} diff --git a/core/agents/lastfm/client.go b/core/agents/lastfm/client.go new file mode 100644 index 0000000..6a24ac8 --- /dev/null +++ b/core/agents/lastfm/client.go @@ -0,0 +1,233 @@ +package lastfm + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/url" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/log" +) + +const ( + apiBaseUrl = "https://ws.audioscrobbler.com/2.0/" +) + +type lastFMError struct { + Code int + Message string +} + +func (e *lastFMError) Error() string { + return fmt.Sprintf("last.fm error(%d): %s", e.Code, e.Message) +} + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +func newClient(apiKey string, secret string, lang string, hc httpDoer) *client { + return &client{apiKey, secret, lang, hc} +} + +type client struct { + apiKey string + secret string + lang string + hc httpDoer +} + +func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string) (*Album, error) { + params := url.Values{} + params.Add("method", "album.getInfo") + params.Add("album", name) + params.Add("artist", artist) + params.Add("mbid", mbid) + params.Add("lang", c.lang) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.Album, nil +} + +func (c *client) artistGetInfo(ctx context.Context, name string) (*Artist, error) { + params := url.Values{} + params.Add("method", "artist.getInfo") + params.Add("artist", name) + params.Add("lang", c.lang) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.Artist, nil +} + +func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) { + params := url.Values{} + params.Add("method", "artist.getSimilar") + params.Add("artist", name) + params.Add("limit", strconv.Itoa(limit)) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.SimilarArtists, nil +} + +func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) { + params := url.Values{} + params.Add("method", "artist.getTopTracks") + params.Add("artist", name) + params.Add("limit", strconv.Itoa(limit)) + response, err := c.makeRequest(ctx, http.MethodGet, params, false) + if err != nil { + return nil, err + } + return &response.TopTracks, nil +} + +func (c *client) GetToken(ctx context.Context) (string, error) { + params := url.Values{} + params.Add("method", "auth.getToken") + c.sign(params) + response, err := c.makeRequest(ctx, http.MethodGet, params, true) + if err != nil { + return "", err + } + return response.Token, nil +} + +func (c *client) getSession(ctx context.Context, token string) (string, error) { + params := url.Values{} + params.Add("method", "auth.getSession") + params.Add("token", token) + response, err := c.makeRequest(ctx, http.MethodGet, params, true) + if err != nil { + return "", err + } + return response.Session.Key, nil +} + +type ScrobbleInfo struct { + artist string + track string + album string + trackNumber int + mbid string + duration int + albumArtist string + timestamp time.Time +} + +func (c *client) updateNowPlaying(ctx context.Context, sessionKey string, info ScrobbleInfo) error { + params := url.Values{} + params.Add("method", "track.updateNowPlaying") + params.Add("artist", info.artist) + params.Add("track", info.track) + params.Add("album", info.album) + params.Add("trackNumber", strconv.Itoa(info.trackNumber)) + params.Add("mbid", info.mbid) + params.Add("duration", strconv.Itoa(info.duration)) + params.Add("albumArtist", info.albumArtist) + params.Add("sk", sessionKey) + resp, err := c.makeRequest(ctx, http.MethodPost, params, true) + if err != nil { + return err + } + if resp.NowPlaying.IgnoredMessage.Code != "0" { + log.Warn(ctx, "LastFM: NowPlaying was ignored", "code", resp.NowPlaying.IgnoredMessage.Code, + "text", resp.NowPlaying.IgnoredMessage.Text) + } + return nil +} + +func (c *client) scrobble(ctx context.Context, sessionKey string, info ScrobbleInfo) error { + params := url.Values{} + params.Add("method", "track.scrobble") + params.Add("timestamp", strconv.FormatInt(info.timestamp.Unix(), 10)) + params.Add("artist", info.artist) + params.Add("track", info.track) + params.Add("album", info.album) + params.Add("trackNumber", strconv.Itoa(info.trackNumber)) + params.Add("mbid", info.mbid) + params.Add("duration", strconv.Itoa(info.duration)) + params.Add("albumArtist", info.albumArtist) + params.Add("sk", sessionKey) + resp, err := c.makeRequest(ctx, http.MethodPost, params, true) + if err != nil { + return err + } + if resp.Scrobbles.Scrobble.IgnoredMessage.Code != "0" { + log.Warn(ctx, "LastFM: scrobble was ignored", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code, + "text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info) + } + if resp.Scrobbles.Attr.Accepted != 1 { + log.Warn(ctx, "LastFM: scrobble was not accepted", "code", resp.Scrobbles.Scrobble.IgnoredMessage.Code, + "text", resp.Scrobbles.Scrobble.IgnoredMessage.Text, "info", info) + } + return nil +} + +func (c *client) makeRequest(ctx context.Context, method string, params url.Values, signed bool) (*Response, error) { + params.Add("format", "json") + params.Add("api_key", c.apiKey) + + if signed { + c.sign(params) + } + + 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) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + decoder := json.NewDecoder(resp.Body) + + var response Response + jsonErr := decoder.Decode(&response) + if resp.StatusCode != 200 && jsonErr != nil { + return nil, fmt.Errorf("last.fm http status: (%d)", resp.StatusCode) + } + if jsonErr != nil { + return nil, jsonErr + } + if response.Error != 0 { + return &response, &lastFMError{Code: response.Error, Message: response.Message} + } + + return &response, nil +} + +func (c *client) sign(params url.Values) { + // the parameters must be in order before hashing + keys := make([]string, 0, len(params)) + for k := range params { + if slices.Contains([]string{"format", "callback"}, k) { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + msg := strings.Builder{} + for _, k := range keys { + msg.WriteString(k) + msg.WriteString(params[k][0]) + } + msg.WriteString(c.secret) + hash := md5.Sum([]byte(msg.String())) + params.Add("api_sig", hex.EncodeToString(hash[:])) +} diff --git a/core/agents/lastfm/client_test.go b/core/agents/lastfm/client_test.go new file mode 100644 index 0000000..85ec115 --- /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") + 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("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") + 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") + 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") + 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") + 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") + 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")) + }) + }) + + 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")) + }) + }) + + 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/core/agents/lastfm/lastfm_suite_test.go b/core/agents/lastfm/lastfm_suite_test.go new file mode 100644 index 0000000..d809234 --- /dev/null +++ b/core/agents/lastfm/lastfm_suite_test.go @@ -0,0 +1,17 @@ +package lastfm + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestLastFM(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "LastFM Test Suite") +} diff --git a/core/agents/lastfm/responses.go b/core/agents/lastfm/responses.go new file mode 100644 index 0000000..1ceebe7 --- /dev/null +++ b/core/agents/lastfm/responses.go @@ -0,0 +1,119 @@ +package lastfm + +type Response struct { + Artist Artist `json:"artist"` + SimilarArtists SimilarArtists `json:"similarartists"` + TopTracks TopTracks `json:"toptracks"` + Album Album `json:"album"` + Error int `json:"error"` + Message string `json:"message"` + Token string `json:"token"` + Session Session `json:"session"` + NowPlaying NowPlaying `json:"nowplaying"` + Scrobbles Scrobbles `json:"scrobbles"` +} + +type Album struct { + Name string `json:"name"` + MBID string `json:"mbid"` + URL string `json:"url"` + Image []ExternalImage `json:"image"` + Description Description `json:"wiki"` +} + +type Artist struct { + Name string `json:"name"` + MBID string `json:"mbid"` + URL string `json:"url"` + Image []ExternalImage `json:"image"` + Bio Description `json:"bio"` +} + +type SimilarArtists struct { + Artists []Artist `json:"artist"` + Attr Attr `json:"@attr"` +} + +type Attr struct { + Artist string `json:"artist"` +} + +type ExternalImage struct { + URL string `json:"#text"` + Size string `json:"size"` +} + +type Description struct { + Published string `json:"published"` + Summary string `json:"summary"` + Content string `json:"content"` +} + +type Track struct { + Name string `json:"name"` + MBID string `json:"mbid"` +} + +type TopTracks struct { + Track []Track `json:"track"` + Attr Attr `json:"@attr"` +} + +type Session struct { + Name string `json:"name"` + Key string `json:"key"` + Subscriber int `json:"subscriber"` +} + +type NowPlaying struct { + Artist struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"artist"` + IgnoredMessage struct { + Code string `json:"code"` + Text string `json:"#text"` + } `json:"ignoredMessage"` + Album struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"album"` + AlbumArtist struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"albumArtist"` + Track struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"track"` +} + +type Scrobbles struct { + Attr struct { + Accepted int `json:"accepted"` + Ignored int `json:"ignored"` + } `json:"@attr"` + Scrobble struct { + Artist struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"artist"` + IgnoredMessage struct { + Code string `json:"code"` + Text string `json:"#text"` + } `json:"ignoredMessage"` + AlbumArtist struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"albumArtist"` + Timestamp string `json:"timestamp"` + Album struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"album"` + Track struct { + Corrected string `json:"corrected"` + Text string `json:"#text"` + } `json:"track"` + } `json:"scrobble"` +} diff --git a/core/agents/lastfm/responses_test.go b/core/agents/lastfm/responses_test.go new file mode 100644 index 0000000..1601e7d --- /dev/null +++ b/core/agents/lastfm/responses_test.go @@ -0,0 +1,65 @@ +package lastfm + +import ( + "encoding/json" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("LastFM responses", func() { + Describe("Artist", func() { + It("parses the response correctly", func() { + var resp Response + body, _ := os.ReadFile("tests/fixtures/lastfm.artist.getinfo.json") + err := json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.Artist.Name).To(Equal("U2")) + Expect(resp.Artist.MBID).To(Equal("a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432")) + Expect(resp.Artist.URL).To(Equal("https://www.last.fm/music/U2")) + Expect(resp.Artist.Bio.Summary).To(ContainSubstring("U2 é uma das mais importantes bandas de rock de todos os tempos")) + }) + }) + + Describe("SimilarArtists", func() { + It("parses the response correctly", func() { + var resp Response + body, _ := os.ReadFile("tests/fixtures/lastfm.artist.getsimilar.json") + err := json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.SimilarArtists.Artists).To(HaveLen(2)) + Expect(resp.SimilarArtists.Artists[0].Name).To(Equal("Passengers")) + Expect(resp.SimilarArtists.Artists[1].Name).To(Equal("INXS")) + }) + }) + + Describe("TopTracks", func() { + It("parses the response correctly", func() { + var resp Response + body, _ := os.ReadFile("tests/fixtures/lastfm.artist.gettoptracks.json") + err := json.Unmarshal(body, &resp) + Expect(err).To(BeNil()) + + Expect(resp.TopTracks.Track).To(HaveLen(2)) + Expect(resp.TopTracks.Track[0].Name).To(Equal("Beautiful Day")) + Expect(resp.TopTracks.Track[0].MBID).To(Equal("f7f264d0-a89b-4682-9cd7-a4e7c37637af")) + Expect(resp.TopTracks.Track[1].Name).To(Equal("With or Without You")) + Expect(resp.TopTracks.Track[1].MBID).To(Equal("6b9a509f-6907-4a6e-9345-2f12da09ba4b")) + }) + }) + + Describe("Error", func() { + It("parses the error response correctly", func() { + var error Response + body := []byte(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`) + err := json.Unmarshal(body, &error) + Expect(err).To(BeNil()) + + Expect(error.Error).To(Equal(3)) + Expect(error.Message).To(Equal("Invalid Method - No method with that name in this package")) + }) + }) +}) diff --git a/core/agents/lastfm/token_received.html b/core/agents/lastfm/token_received.html new file mode 100644 index 0000000..8fc03a2 --- /dev/null +++ b/core/agents/lastfm/token_received.html @@ -0,0 +1,16 @@ + + + + + Account Linking Success + + +

+ + + diff --git a/core/agents/listenbrainz/agent.go b/core/agents/listenbrainz/agent.go new file mode 100644 index 0000000..769b0f5 --- /dev/null +++ b/core/agents/listenbrainz/agent.go @@ -0,0 +1,129 @@ +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/cache" + "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 := &http.Client{ + Timeout: 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 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 0000000..e99b442 --- /dev/null +++ b/core/agents/listenbrainz/agent_test.go @@ -0,0 +1,165 @@ +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, + 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)) + }) + }) +}) diff --git a/core/agents/listenbrainz/auth_router.go b/core/agents/listenbrainz/auth_router.go new file mode 100644 index 0000000..2382aeb --- /dev/null +++ b/core/agents/listenbrainz/auth_router.go @@ -0,0 +1,121 @@ +package listenbrainz + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "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/consts" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +type sessionKeysRepo interface { + Put(ctx context.Context, userId, sessionKey string) error + Get(ctx context.Context, userId string) (string, error) + Delete(ctx context.Context, userId string) error +} + +type Router struct { + http.Handler + ds model.DataStore + sessionKeys sessionKeysRepo + client *client +} + +func NewRouter(ds model.DataStore) *Router { + r := &Router{ + ds: ds, + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + r.Handler = r.routes() + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc) + return r +} + +func (s *Router) routes() http.Handler { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(server.Authenticator(s.ds)) + r.Use(server.JWTRefresher) + + r.Get("/link", s.getLinkStatus) + r.Put("/link", s.link) + r.Delete("/link", s.unlink) + }) + + return r +} + +func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { + 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) { + resp["error"] = err + resp["status"] = false + _ = rest.RespondWithJSON(w, http.StatusInternalServerError, resp) + return + } + resp["status"] = key != "" + _ = rest.RespondWithJSON(w, http.StatusOK, resp) +} + +func (s *Router) link(w http.ResponseWriter, r *http.Request) { + type tokenPayload struct { + Token string `json:"token"` + } + var payload tokenPayload + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if payload.Token == "" { + _ = rest.RespondWithError(w, http.StatusBadRequest, "Token is required") + return + } + + u, _ := request.UserFrom(r.Context()) + resp, err := s.client.validateToken(r.Context(), payload.Token) + if err != nil { + log.Error(r.Context(), "Could not validate ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err) + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + if !resp.Valid { + _ = rest.RespondWithError(w, http.StatusBadRequest, "Invalid token") + return + } + + err = s.sessionKeys.Put(r.Context(), u.ID, payload.Token) + if err != nil { + log.Error("Could not save ListenBrainz token", "userId", u.ID, "requestId", middleware.GetReqID(r.Context()), err) + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]interface{}{"status": resp.Valid, "user": resp.UserName}) +} + +func (s *Router) unlink(w http.ResponseWriter, r *http.Request) { + u, _ := request.UserFrom(r.Context()) + err := s.sessionKeys.Delete(r.Context(), u.ID) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + } else { + _ = rest.RespondWithJSON(w, http.StatusOK, map[string]string{}) + } +} diff --git a/core/agents/listenbrainz/auth_router_test.go b/core/agents/listenbrainz/auth_router_test.go new file mode 100644 index 0000000..dc705db --- /dev/null +++ b/core/agents/listenbrainz/auth_router_test.go @@ -0,0 +1,130 @@ +package listenbrainz + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ListenBrainz Auth Router", func() { + var sk *fakeSessionKeys + var httpClient *tests.FakeHttpClient + var r Router + var req *http.Request + var resp *httptest.ResponseRecorder + + BeforeEach(func() { + sk = &fakeSessionKeys{KeyName: sessionKeyProperty} + httpClient = &tests.FakeHttpClient{} + cl := newClient("http://localhost/", httpClient) + r = Router{ + sessionKeys: sk, + client: cl, + } + resp = httptest.NewRecorder() + }) + + Describe("getLinkStatus", func() { + It("returns false when there is no stored session key", func() { + req = httptest.NewRequest("GET", "/listenbrainz/link", nil) + r.getLinkStatus(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + var parsed map[string]interface{} + Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) + Expect(parsed["status"]).To(Equal(false)) + }) + + It("returns true when there is a stored session key", func() { + sk.KeyValue = "sk-1" + req = httptest.NewRequest("GET", "/listenbrainz/link", nil) + r.getLinkStatus(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + var parsed map[string]interface{} + Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) + Expect(parsed["status"]).To(Equal(true)) + }) + }) + + Describe("link", func() { + It("returns bad request when no token is sent", func() { + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns bad request when the token is invalid", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token invalid.", "valid": false}`)), + StatusCode: 200, + } + + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "invalid-tok-1"}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns true and the username when the token is valid", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), + StatusCode: 200, + } + + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + var parsed map[string]interface{} + Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) + Expect(parsed["status"]).To(Equal(true)) + Expect(parsed["user"]).To(Equal("ListenBrainzUser")) + }) + + It("saves the session key when the token is valid", func() { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)), + StatusCode: 200, + } + + req = httptest.NewRequest("PUT", "/listenbrainz/link", strings.NewReader(`{"token": "tok-1"}`)) + r.link(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + Expect(sk.KeyValue).To(Equal("tok-1")) + }) + }) + + Describe("unlink", func() { + It("removes the session key when unlinking", func() { + sk.KeyValue = "tok-1" + req = httptest.NewRequest("DELETE", "/listenbrainz/link", nil) + r.unlink(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + Expect(sk.KeyValue).To(Equal("")) + }) + }) +}) + +type fakeSessionKeys struct { + KeyName string + KeyValue string +} + +func (sk *fakeSessionKeys) Put(ctx context.Context, userId, sessionKey string) error { + sk.KeyValue = sessionKey + return nil +} + +func (sk *fakeSessionKeys) Get(ctx context.Context, userId string) (string, error) { + return sk.KeyValue, nil +} + +func (sk *fakeSessionKeys) Delete(ctx context.Context, userId string) error { + sk.KeyValue = "" + return nil +} diff --git a/core/agents/listenbrainz/client.go b/core/agents/listenbrainz/client.go new file mode 100644 index 0000000..168aad5 --- /dev/null +++ b/core/agents/listenbrainz/client.go @@ -0,0 +1,179 @@ +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"` + 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.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 0000000..680a7d1 --- /dev/null +++ b/core/agents/listenbrainz/client_test.go @@ -0,0 +1,120 @@ +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, + 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)) + }) + }) + }) +}) diff --git a/core/agents/listenbrainz/listenbrainz_suite_test.go b/core/agents/listenbrainz/listenbrainz_suite_test.go new file mode 100644 index 0000000..64abbe4 --- /dev/null +++ b/core/agents/listenbrainz/listenbrainz_suite_test.go @@ -0,0 +1,17 @@ +package listenbrainz + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestListenBrainz(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "ListenBrainz Test Suite") +} diff --git a/core/agents/local_agent.go b/core/agents/local_agent.go new file mode 100644 index 0000000..ce8f9f0 --- /dev/null +++ b/core/agents/local_agent.go @@ -0,0 +1,52 @@ +package agents + +import ( + "context" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" +) + +const LocalAgentName = "local" + +type localAgent struct { + ds model.DataStore +} + +func localsConstructor(ds model.DataStore) Interface { + return &localAgent{ds} +} + +func (p *localAgent) AgentName() string { + return LocalAgentName +} + +func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) { + top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Sort: "playCount", + Order: "desc", + Max: count, + Filters: squirrel.And{ + squirrel.Eq{"artist_id": id}, + squirrel.Or{ + squirrel.Eq{"starred": true}, + squirrel.Eq{"rating": 5}, + }, + }, + }) + if err != nil { + return nil, err + } + var result []Song + for _, s := range top { + result = append(result, Song{ + Name: s.Title, + MBID: s.MbzReleaseTrackID, + }) + } + return result, nil +} + +func init() { + Register(LocalAgentName, localsConstructor) +} diff --git a/core/agents/session_keys.go b/core/agents/session_keys.go new file mode 100644 index 0000000..cea6005 --- /dev/null +++ b/core/agents/session_keys.go @@ -0,0 +1,25 @@ +package agents + +import ( + "context" + + "github.com/navidrome/navidrome/model" +) + +// SessionKeys is a simple wrapper around the UserPropsRepository +type SessionKeys struct { + model.DataStore + KeyName string +} + +func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error { + 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(ctx).Get(userId, sk.KeyName) +} + +func (sk *SessionKeys) Delete(ctx context.Context, userId string) error { + return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName) +} diff --git a/core/agents/session_keys_test.go b/core/agents/session_keys_test.go new file mode 100644 index 0000000..e0232c0 --- /dev/null +++ b/core/agents/session_keys_test.go @@ -0,0 +1,37 @@ +package agents + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SessionKeys", func() { + ctx := context.Background() + user := model.User{ID: "u-1"} + ds := &tests.MockDataStore{MockedUserProps: &tests.MockedUserPropsRepo{}} + sk := SessionKeys{DataStore: ds, KeyName: "fakeSessionKey"} + + It("uses the assigned key name", func() { + Expect(sk.KeyName).To(Equal("fakeSessionKey")) + }) + It("stores a value in the DB", func() { + Expect(sk.Put(ctx, user.ID, "test-stored-value")).To(BeNil()) + }) + It("fetches the stored value", func() { + value, err := sk.Get(ctx, user.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(value).To(Equal("test-stored-value")) + }) + It("deletes the stored value", func() { + Expect(sk.Delete(ctx, user.ID)).To(BeNil()) + }) + It("handles a not found value", func() { + _, err := sk.Get(ctx, "u-2") + Expect(err).To(MatchError(model.ErrNotFound)) + }) +}) diff --git a/core/agents/spotify/client.go b/core/agents/spotify/client.go new file mode 100644 index 0000000..25b1f9e --- /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 0000000..2782d21 --- /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 0000000..21166bf --- /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 0000000..7041198 --- /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 0000000..633c329 --- /dev/null +++ b/core/agents/spotify/spotify.go @@ -0,0 +1,96 @@ +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/cache" + "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 { + if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" { + return nil + } + l := &spotifyAgent{ + ds: ds, + id: conf.Server.Spotify.ID, + secret: conf.Server.Spotify.Secret, + } + hc := &http.Client{ + Timeout: consts.DefaultHttpClientTimeOut, + } + chc := cache.NewHTTPClient(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() { + agents.Register(spotifyAgentName, spotifyConstructor) + }) +} diff --git a/core/agents/spotify/spotify_suite_test.go b/core/agents/spotify/spotify_suite_test.go new file mode 100644 index 0000000..275b05e --- /dev/null +++ b/core/agents/spotify/spotify_suite_test.go @@ -0,0 +1,17 @@ +package spotify + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSpotify(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Spotify Test Suite") +} diff --git a/core/archiver.go b/core/archiver.go new file mode 100644 index 0000000..6345981 --- /dev/null +++ b/core/archiver.go @@ -0,0 +1,201 @@ +package core + +import ( + "archive/zip" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +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, id string, w io.Writer) error + ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error +} + +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 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}) +} + +func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { + return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_artist_id": id}) +} + +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.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 + log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, + "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) + for _, mf := range album { + file := a.albumFilename(mf, format, isMultiDisc) + _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + } + } + err = z.Close() + if err != nil { + log.Error(ctx, "Error closing zip file", "id", id, err) + } + return err +} + +func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer { + z := zip.NewWriter(out) + comment := "Downloaded from Navidrome" + if format != "raw" && format != "" { + comment = fmt.Sprintf("%s, transcoded to %s %dbps", comment, format, bitrate) + } + _ = z.SetComment(comment) + return z +} + +func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string { + _, file := filepath.Split(mf.Path) + if format != "raw" { + file = strings.TrimSuffix(file, mf.Suffix) + format + } + if isMultiDisc { + file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) + } + return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file) +} + +func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { + s, err := a.shares.Load(ctx, id) + if err != nil { + return err + } + if !s.Downloadable { + return model.ErrNotAuthorized + } + log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks)) + return a.zipMediaFiles(ctx, id, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false) +} + +func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error { + pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false) + 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, true) +} + +func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error { + z := createZipWriter(out, format, bitrate) + + zippedMfs := make(model.MediaFiles, len(mfs)) + for idx, mf := range mfs { + file := a.playlistFilename(mf, format, idx) + _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + mf.Path = file + zippedMfs[idx] = mf + } + + // Add M3U file if requested + if addM3U && len(zippedMfs) > 0 { + plsName := sanitizeName(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) + } + return err +} + +func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) string { + ext := mf.Suffix + if format != "" && format != "raw" { + ext = format + } + return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext) +} + +func sanitizeName(target string) string { + return strings.ReplaceAll(target, "/", "_") +} + +func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { + path := mf.AbsolutePath() + 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) + return err + } + + var r io.ReadCloser + if format != "raw" && format != "" { + r, err = a.ms.DoStream(ctx, &mf, format, bitrate, 0) + } else { + r, err = os.Open(path) + } + if err != nil { + log.Error(ctx, "Error opening file for zipping", "file", path, "format", 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) + } + }() + + _, err = io.Copy(w, r) + if err != nil { + log.Error(ctx, "Error zipping file", "file", path, err) + return err + } + + return nil +} diff --git a/core/archiver_test.go b/core/archiver_test.go new file mode 100644 index 0000000..37c4ef9 --- /dev/null +++ b/core/archiver_test.go @@ -0,0 +1,236 @@ +package core_test + +import ( + "archive/zip" + "bytes" + "context" + "io" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Archiver", func() { + var ( + arch core.Archiver + ms *mockMediaStreamer + ds *mockDataStore + sh *mockShare + ) + + BeforeEach(func() { + ms = &mockMediaStreamer{} + sh = &mockShare{} + ds = &mockDataStore{} + arch = core.NewArchiver(ms, ds, sh) + }) + + Context("ZipAlbum", func() { + It("zips an album correctly", func() { + mfs := model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1}, + } + + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{ + Filters: squirrel.Eq{"album_id": "1"}, + Sort: "album", + }}).Return(mfs, nil) + + ds.On("MediaFile", mock.Anything).Return(mfRepo) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3) + + out := new(bytes.Buffer) + err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) + Expect(err).To(BeNil()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).To(BeNil()) + + Expect(len(zr.File)).To(Equal(2)) + Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3")) + Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3")) + }) + }) + + Context("ZipArtist", func() { + It("zips an artist's albums correctly", func() { + mfs := model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumArtistID: "1", AlbumID: "1", Album: "Album 1", DiscNumber: 1}, + } + + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{ + Filters: squirrel.Eq{"album_artist_id": "1"}, + Sort: "album", + }}).Return(mfs, nil) + + ds.On("MediaFile", mock.Anything).Return(mfRepo) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + + out := new(bytes.Buffer) + err := arch.ZipArtist(context.Background(), "1", "mp3", 128, out) + Expect(err).To(BeNil()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).To(BeNil()) + + Expect(len(zr.File)).To(Equal(2)) + Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3")) + Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3")) + }) + }) + + Context("ZipShare", func() { + It("zips a share correctly", func() { + mfs := model.MediaFiles{ + {ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"}, + {ID: "2", Path: "test_data/02 - track2.mp3", Suffix: "mp3", Artist: "Artist 2", Title: "track2"}, + } + + share := &model.Share{ + ID: "1", + Downloadable: true, + Format: "mp3", + MaxBitRate: 128, + Tracks: mfs, + } + + sh.On("Load", mock.Anything, "1").Return(share, nil) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + + out := new(bytes.Buffer) + err := arch.ZipShare(context.Background(), "1", out) + Expect(err).To(BeNil()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).To(BeNil()) + + Expect(len(zr.File)).To(Equal(2)) + Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.mp3")) + Expect(zr.File[1].Name).To(Equal("02 - Artist 2 - track2.mp3")) + + }) + }) + + Context("ZipPlaylist", func() { + 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"}}, + {MediaFile: model.MediaFile{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "Artist 2", Title: "track2"}}, + } + + pls := &model.Playlist{ + ID: "1", + Name: "Test Playlist", + Tracks: tracks, + } + + plRepo := &mockPlaylistRepository{} + plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil) + ds.On("Playlist", mock.Anything).Return(plRepo) + ms.On("DoStream", mock.Anything, mock.Anything, "mp3", 128, 0).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2) + + out := new(bytes.Buffer) + err := arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out) + Expect(err).To(BeNil()) + + zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len())) + Expect(err).To(BeNil()) + + Expect(len(zr.File)).To(Equal(3)) + 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)) + }) + }) +}) + +type mockDataStore struct { + mock.Mock + model.DataStore +} + +func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + args := m.Called(ctx) + return args.Get(0).(model.MediaFileRepository) +} + +func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { + args := m.Called(ctx) + return args.Get(0).(model.PlaylistRepository) +} + +func (m *mockDataStore) Library(context.Context) model.LibraryRepository { + return &mockLibraryRepository{} +} + +type mockLibraryRepository struct { + mock.Mock + model.LibraryRepository +} + +func (m *mockLibraryRepository) GetPath(id int) (string, error) { + return "/music", nil +} + +type mockMediaFileRepository struct { + mock.Mock + model.MediaFileRepository +} + +func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + args := m.Called(options) + return args.Get(0).(model.MediaFiles), args.Error(1) +} + +type mockPlaylistRepository struct { + mock.Mock + model.PlaylistRepository +} + +func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) { + args := m.Called(id, refreshSmartPlaylists, includeMissing) + return args.Get(0).(*model.Playlist), args.Error(1) +} + +type mockMediaStreamer struct { + mock.Mock + core.MediaStreamer +} + +func (m *mockMediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset int) (*core.Stream, error) { + args := m.Called(ctx, mf, reqFormat, reqBitRate, reqOffset) + if args.Error(1) != nil { + return nil, args.Error(1) + } + return &core.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil +} + +type mockShare struct { + mock.Mock + core.Share +} + +func (m *mockShare) Load(ctx context.Context, id string) (*model.Share, error) { + args := m.Called(ctx, id) + return args.Get(0).(*model.Share), args.Error(1) +} diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go new file mode 100644 index 0000000..2e92b24 --- /dev/null +++ b/core/artwork/artwork.go @@ -0,0 +1,130 @@ +package artwork + +import ( + "context" + "errors" + _ "image/gif" + "io" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/utils/cache" + _ "golang.org/x/image/webp" +) + +var ErrUnavailable = errors.New("artwork unavailable") + +type Artwork interface { + Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) + GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) +} + +func NewArtwork(ds model.DataStore, cache cache.FileCache, ffmpeg ffmpeg.FFmpeg, provider external.Provider) Artwork { + return &artwork{ds: ds, cache: cache, ffmpeg: ffmpeg, provider: provider} +} + +type artwork struct { + ds model.DataStore + cache cache.FileCache + ffmpeg ffmpeg.FFmpeg + provider external.Provider +} + +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, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { + artID, err := a.getArtworkId(ctx, id) + if err == nil { + reader, lastUpdate, err = a.Get(ctx, artID, size, square) + } + if errors.Is(err, ErrUnavailable) { + if artID.Kind == model.KindArtistArtwork { + reader, _ = resources.FS().Open(consts.PlaceholderArtistArt) + } else { + reader, _ = resources.FS().Open(consts.PlaceholderAlbumArt) + } + return reader, consts.ServerStart, nil + } + return reader, lastUpdate, err +} + +func (a *artwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (reader io.ReadCloser, lastUpdate time.Time, err error) { + artReader, err := a.getArtworkReader(ctx, artID, size, square) + if err != nil { + return nil, time.Time{}, err + } + + r, err := a.cache.Get(ctx, artReader) + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrUnavailable) { + log.Error(ctx, "Error accessing image cache", "id", artID, "size", size, err) + } + return nil, time.Time{}, err + } + return r, artReader.LastUpdated(), nil +} + +type coverArtGetter interface { + CoverArtID() model.ArtworkID +} + +func (a *artwork) getArtworkId(ctx context.Context, id string) (model.ArtworkID, error) { + if id == "" { + return model.ArtworkID{}, ErrUnavailable + } + artID, err := model.ParseArtworkID(id) + if err == nil { + return artID, nil + } + + 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.(coverArtGetter); ok { + artID = e.CoverArtID() + } + 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 +} + +func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, size int, square bool) (artworkReader, error) { + var artReader artworkReader + var err error + if size > 0 || square { + artReader, err = resizedFromOriginal(ctx, a, artID, size, square) + } else { + switch artID.Kind { + case model.KindArtistArtwork: + artReader, err = newArtistArtworkReader(ctx, a, artID, a.provider) + case model.KindAlbumArtwork: + artReader, err = newAlbumArtworkReader(ctx, a, artID, a.provider) + case model.KindMediaFileArtwork: + artReader, err = newMediafileArtworkReader(ctx, a, artID) + case model.KindPlaylistArtwork: + artReader, err = newPlaylistArtworkReader(ctx, a, artID) + default: + return nil, ErrUnavailable + } + } + return artReader, err +} diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go new file mode 100644 index 0000000..cfb7850 --- /dev/null +++ b/core/artwork/artwork_internal_test.go @@ -0,0 +1,328 @@ +package artwork + +import ( + "context" + "errors" + "image" + "image/jpeg" + "image/png" + "io" + "os" + "path/filepath" + + "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" +) + +var _ = Describe("Artwork", func() { + var aw *artwork + var ds model.DataStore + var ffmpeg *tests.MockFFmpeg + var folderRepo *fakeFolderRepo + 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.*" + + folderRepo = &fakeFolderRepo{} + ds = &tests.MockDataStore{ + MockedTranscoding: &tests.MockTranscodingRepo{}, + MockedFolder: folderRepo, + } + alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} + alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} + alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}} + alExternalNotFound = model.Album{ID: "555", Name: "External not found", FolderIDs: []string{"f2"}} + arMultipleCovers = model.Artist{ID: "777", Name: "All options"} + alMultipleCovers = model.Album{ + ID: "666", + Name: "All options", + EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", + FolderIDs: []string{"f1"}, + 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() { + folderRepo.result = nil + 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() { + folderRepo.result = []model.Folder{} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alOnlyExternal, + alExternalNotFound, + }) + }) + It("returns external cover", func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"front.png"}, + }} + 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() { + folderRepo.result = []model.Folder{} + 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() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg", "front.png", "artist.png"}, + }} + 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() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, + }} + 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 := newArtistArtworkReader(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 := newMediafileArtworkReader(ctx, aw, model.MustParseArtworkID("mf-NOT-FOUND")) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + Context("Embed images", func() { + BeforeEach(func() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"front.png"}, + }} + 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()) + data, _ := io.ReadAll(r) + Expect(data).ToNot(BeEmpty()) + Expect(path).To(Equal("tests/fixtures/test.ogg")) + }) + It("returns album cover if cannot read embed artwork", func() { + // Force fromTag to fail + mfCorruptedCover.Path = "tests/fixtures/DOES_NOT_EXIST.ogg" + Expect(ds.MediaFile(ctx).(*tests.MockMediaFileRepo).Put(&mfCorruptedCover)).To(Succeed()) + // Simulate ffmpeg error + 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() { + folderRepo.result = []model.Folder{{ + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"cover.jpg", "front.png"}, + }} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alMultipleCovers, + }) + }) + When("Square is false", func() { + 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, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(15)) + Expect(img.Bounds().Size().Y).To(Equal(15)) + }) + It("returns a JPEG if original image is not a PNG", func() { + conf.Server.CoverArtPriority = "cover.jpg" + r, _, err := aw.Get(context.Background(), alMultipleCovers.CoverArtID(), 200, false) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(format).To(Equal("jpeg")) + Expect(err).ToNot(HaveOccurred()) + Expect(img.Bounds().Size().X).To(Equal(200)) + Expect(img.Bounds().Size().Y).To(Equal(200)) + }) + }) + When("When square is true", func() { + var alCover model.Album + + DescribeTable("resize", + func(format string, landscape bool, size int) { + coverFileName := "cover." + format + dirName := createImage(format, landscape, size) + alCover = model.Album{ + ID: "444", + Name: "Only external", + FolderIDs: []string{"tmp"}, + } + folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}} + ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ + alCover, + }) + + conf.Server.CoverArtPriority = coverFileName + r, _, err := aw.Get(context.Background(), alCover.CoverArtID(), size, true) + Expect(err).ToNot(HaveOccurred()) + + img, format, err := image.Decode(r) + Expect(err).ToNot(HaveOccurred()) + Expect(format).To(Equal("png")) + Expect(img.Bounds().Size().X).To(Equal(size)) + Expect(img.Bounds().Size().Y).To(Equal(size)) + }, + Entry("portrait png image", "png", false, 200), + Entry("landscape png image", "png", true, 200), + Entry("portrait jpg image", "jpg", false, 200), + Entry("landscape jpg image", "jpg", true, 200), + ) + }) + }) +}) + +func createImage(format string, landscape bool, size int) string { + var img image.Image + + if landscape { + img = image.NewRGBA(image.Rect(0, 0, size, size/2)) + } else { + img = image.NewRGBA(image.Rect(0, 0, size/2, size)) + } + + tmpDir := GinkgoT().TempDir() + f, _ := os.Create(filepath.Join(tmpDir, "cover."+format)) + defer f.Close() + switch format { + case "png": + _ = png.Encode(f, img) + case "jpg": + _ = jpeg.Encode(f, img, &jpeg.Options{Quality: 75}) + } + + return tmpDir +} diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go new file mode 100644 index 0000000..dfd66e5 --- /dev/null +++ b/core/artwork/artwork_suite_test.go @@ -0,0 +1,17 @@ +package artwork + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtwork(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork Suite") +} diff --git a/core/artwork/artwork_test.go b/core/artwork/artwork_test.go new file mode 100644 index 0000000..adddd0d --- /dev/null +++ b/core/artwork/artwork_test.go @@ -0,0 +1,57 @@ +package artwork_test + +import ( + "context" + "io" + + "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/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Artwork", func() { + var aw artwork.Artwork + var ds model.DataStore + var ffmpeg *tests.MockFFmpeg + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.ImageCacheSize = "0" // Disable cache + cache := artwork.GetImageCache() + ffmpeg = tests.NewMockFFmpeg("content from ffmpeg") + aw = artwork.NewArtwork(ds, cache, ffmpeg, nil) + }) + + 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, false) + Expect(err).ToNot(HaveOccurred()) + + 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)) + }) + }) + }) + Context("Get", func() { + Context("Empty ID", func() { + It("returns an ErrUnavailable error", func() { + _, _, err := aw.Get(context.Background(), model.ArtworkID{}, 0, false) + Expect(err).To(MatchError(artwork.ErrUnavailable)) + }) + }) + }) +}) diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go new file mode 100644 index 0000000..909d299 --- /dev/null +++ b/core/artwork/cache_warmer.go @@ -0,0 +1,163 @@ +package artwork + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "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" +) + +type CacheWarmer interface { + PreCache(artID model.ArtworkID) +} + +// NewCacheWarmer creates a new CacheWarmer instance. The CacheWarmer will pre-cache Artwork images in the background +// to speed up the response time when the image is requested by the UI. The cache is pre-populated with the original +// image size, as well as the size defined in the UICoverArtSize constant. +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{} + } + + // If the file cache is disabled, return a NOOP implementation + if cache.Disabled(context.Background()) { + log.Debug("Image cache disabled. Cache warmer will not run") + 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{} +} + +func (a *cacheWarmer) PreCache(artID model.ArtworkID) { + if a.cache.Disabled(context.Background()) { + 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 a.cache.Disabled(ctx) { + a.mutex.Lock() + pending := len(a.buffer) + a.buffer = make(map[model.ArtworkID]struct{}) + a.mutex.Unlock() + if pending > 0 { + log.Trace(ctx, "Cache disabled, discarding precache buffer", "bufferLen", pending) + } + return + } + + // If cache not available, keep waiting + if !a.cache.Available(ctx) { + a.mutex.Lock() + bufferLen := len(a.buffer) + a.mutex.Unlock() + if bufferLen > 0 { + log.Trace(ctx, "Cache not available, buffering precache request", "bufferLen", bufferLen) + } + continue + } + + a.mutex.Lock() + + // If there's nothing to send, keep waiting + if len(a.buffer) == 0 { + a.mutex.Unlock() + continue + } + + batch := slices.Collect(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) { + select { + case <-time.After(timeout): + 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.Debug(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, true) + if err != nil { + return fmt.Errorf("caching id='%s': %w", id, err) + } + defer r.Close() + _, err = io.Copy(io.Discard, r) + if err != nil { + return err + } + return nil +} + +func NoopCacheWarmer() CacheWarmer { + return &noopCacheWarmer{} +} + +type noopCacheWarmer struct{} + +func (a *noopCacheWarmer) PreCache(model.ArtworkID) {} diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go new file mode 100644 index 0000000..7ae3a16 --- /dev/null +++ b/core/artwork/cache_warmer_test.go @@ -0,0 +1,222 @@ +package artwork + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("CacheWarmer", func() { + var ( + fc *mockFileCache + aw *mockArtwork + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + fc = &mockFileCache{} + aw = &mockArtwork{} + }) + + Context("initialization", func() { + It("returns noop when cache is disabled", func() { + fc.SetDisabled(true) + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*noopCacheWarmer) + Expect(ok).To(BeTrue()) + }) + + It("returns noop when ImageCacheSize is 0", func() { + conf.Server.ImageCacheSize = "0" + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*noopCacheWarmer) + Expect(ok).To(BeTrue()) + }) + + It("returns noop when EnableArtworkPrecache is false", func() { + conf.Server.EnableArtworkPrecache = false + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*noopCacheWarmer) + Expect(ok).To(BeTrue()) + }) + + It("returns real implementation when properly configured", func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + cw := NewCacheWarmer(aw, fc) + _, ok := cw.(*cacheWarmer) + Expect(ok).To(BeTrue()) + }) + }) + + Context("buffer management", func() { + BeforeEach(func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + }) + + It("drops buffered items when cache becomes disabled", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-test")) + fc.SetDisabled(true) + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + + It("adds multiple items to buffer", func() { + fc.SetReady(false) // Make cache unavailable so items stay in buffer + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-1")) + cw.PreCache(model.MustParseArtworkID("al-2")) + cw.mutex.Lock() + defer cw.mutex.Unlock() + Expect(len(cw.buffer)).To(Equal(2)) + }) + + It("deduplicates items in buffer", func() { + fc.SetReady(false) // Make cache unavailable so items stay in buffer + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-1")) + cw.PreCache(model.MustParseArtworkID("al-1")) + cw.mutex.Lock() + defer cw.mutex.Unlock() + Expect(len(cw.buffer)).To(Equal(1)) + }) + }) + + Context("error handling", func() { + BeforeEach(func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + }) + + It("continues processing after artwork retrieval error", func() { + aw.err = errors.New("artwork error") + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-error")) + cw.PreCache(model.MustParseArtworkID("al-1")) + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + + It("continues processing after cache error", func() { + fc.err = errors.New("cache error") + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-error")) + cw.PreCache(model.MustParseArtworkID("al-1")) + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + }) + + Context("background processing", func() { + BeforeEach(func() { + conf.Server.ImageCacheSize = "100MB" + conf.Server.EnableArtworkPrecache = true + fc.SetDisabled(false) + }) + + It("processes items in batches", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + for i := 0; i < 5; i++ { + cw.PreCache(model.MustParseArtworkID(fmt.Sprintf("al-%d", i))) + } + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + + It("wakes up on new items", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + + // Add first batch + cw.PreCache(model.MustParseArtworkID("al-1")) + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + + // Add second batch + cw.PreCache(model.MustParseArtworkID("al-2")) + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + }) + }) +}) + +type mockArtwork struct { + err error +} + +func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { + if m.err != nil { + return nil, time.Time{}, m.err + } + return io.NopCloser(strings.NewReader("test")), time.Now(), nil +} + +func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + return m.Get(ctx, model.ArtworkID{}, size, square) +} + +type mockFileCache struct { + disabled atomic.Bool + ready atomic.Bool + err error +} + +func (f *mockFileCache) Get(ctx context.Context, item cache.Item) (*cache.CachedStream, error) { + if f.err != nil { + return nil, f.err + } + return &cache.CachedStream{Reader: io.NopCloser(strings.NewReader("cached"))}, nil +} + +func (f *mockFileCache) Available(ctx context.Context) bool { + return f.ready.Load() && !f.disabled.Load() +} + +func (f *mockFileCache) Disabled(ctx context.Context) bool { + return f.disabled.Load() +} + +func (f *mockFileCache) SetDisabled(v bool) { + f.disabled.Store(v) + f.ready.Store(true) +} + +func (f *mockFileCache) SetReady(v bool) { + f.ready.Store(v) +} diff --git a/core/artwork/image_cache.go b/core/artwork/image_cache.go new file mode 100644 index 0000000..ac0f637 --- /dev/null +++ b/core/artwork/image_cache.go @@ -0,0 +1,44 @@ +package artwork + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/singleton" +) + +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 { + cache.FileCache +} + +func GetImageCache() cache.FileCache { + return singleton.GetInstance(func() *imageCache { + return &imageCache{ + FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems, + func(ctx context.Context, arg cache.Item) (io.Reader, error) { + r, _, err := arg.(artworkReader).Reader(ctx) + return r, err + }), + } + }) +} diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go new file mode 100644 index 0000000..cb4db97 --- /dev/null +++ b/core/artwork/reader_album.go @@ -0,0 +1,147 @@ +package artwork + +import ( + "cmp" + "context" + "crypto/md5" + "fmt" + "io" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/maruel/natural" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" +) + +type albumArtworkReader struct { + cacheKey + a *artwork + provider external.Provider + album model.Album + updatedAt *time.Time + imgFiles []string + rootFolder string +} + +func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { + al, err := artwork.ds.Album(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + _, imgFiles, imagesUpdateAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, *al) + if err != nil { + return nil, err + } + a := &albumArtworkReader{ + a: artwork, + provider: provider, + album: *al, + updatedAt: imagesUpdateAt, + imgFiles: imgFiles, + rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""), + } + a.cacheKey.artID = artID + if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { + a.cacheKey.lastUpdate = *a.updatedAt + } else { + 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": + embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath) + ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath)) + case pattern == "external": + ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) + case len(a.imgFiles) > 0: + ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern)) + } + } + return ff +} + +func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...model.Album) ([]string, []string, *time.Time, error) { + var folderIDs []string + for _, album := range albums { + folderIDs = append(folderIDs, album.FolderIDs...) + } + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}}) + if err != nil { + return nil, nil, nil, err + } + var paths []string + var imgFiles []string + var updatedAt time.Time + for _, f := range folders { + path := f.AbsolutePath() + paths = append(paths, path) + if f.ImagesUpdatedAt.After(updatedAt) { + updatedAt = f.ImagesUpdatedAt + } + for _, img := range f.ImageFiles { + imgFiles = append(imgFiles, filepath.Join(path, img)) + } + } + + // Sort image files to ensure consistent selection of cover art + // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg) + // by comparing base filenames without extensions + slices.SortFunc(imgFiles, compareImageFiles) + + return paths, imgFiles, &updatedAt, nil +} + +// compareImageFiles compares two image file paths for sorting. +// It extracts the base filename (without extension) and compares case-insensitively. +// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1". +// Note: This function is called O(n log n) times during sorting, but in practice albums +// typically have only 1-20 image files, making the repeated string operations negligible. +func compareImageFiles(a, b string) int { + // Case-insensitive comparison + a = strings.ToLower(a) + b = strings.ToLower(b) + + // Extract base filenames without extensions + baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a)) + baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b)) + + // Compare base names first, then full paths if equal + return cmp.Or( + natural.Compare(baseA, baseB), + natural.Compare(a, b), + ) +} diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go new file mode 100644 index 0000000..fd5f8a2 --- /dev/null +++ b/core/artwork/reader_album_test.go @@ -0,0 +1,120 @@ +package artwork + +import ( + "context" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Album Artwork Reader", func() { + Describe("loadAlbumFoldersPaths", func() { + var ( + ctx context.Context + ds *fakeDataStore + repo *fakeFolderRepo + album model.Album + now time.Time + expectedAt time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + now = time.Now().Truncate(time.Second) + expectedAt = now.Add(5 * time.Minute) + + // Set up the test folders with image files + repo = &fakeFolderRepo{} + ds = &fakeDataStore{ + folderRepo: repo, + } + album = model.Album{ + ID: "album1", + Name: "Album", + FolderIDs: []string{"folder1", "folder2", "folder3"}, + } + }) + + It("returns sorted image files", func() { + repo.result = []model.Folder{ + { + Path: "Artist/Album/Disc1", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"}, + }, + { + Path: "Artist/Album/Disc2", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + Path: "Artist/Album/Disc10", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + + // Check that image files are sorted by base name (without extension) + Expect(imgFiles).To(HaveLen(5)) + + // Files should be sorted by base filename without extension, then by full path + // "back" < "cover", so back.jpg comes first + // Then all cover.jpg files, sorted by path + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg"))) + Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg"))) + Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg"))) + Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg"))) + Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg"))) + }) + + It("prioritizes files without numeric suffixes", func() { + // Test case for issue #4683: cover.jpg should come before cover.1.jpg + repo.result = []model.Folder{ + { + Path: "Artist/Album", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(3)) + + // cover.jpg should come first because "cover" < "cover.1" < "cover.2" + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg"))) + Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg"))) + }) + + It("handles case-insensitive sorting", func() { + // Test that Cover.jpg and cover.jpg are treated as equivalent + repo.result = []model.Folder{ + { + Path: "Artist/Album", + ImagesUpdatedAt: now, + ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"}, + }, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(3)) + + // Files should be sorted case-insensitively: BACK, cover, Folder + Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg"))) + Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg"))) + }) + }) +}) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go new file mode 100644 index 0000000..da8141a --- /dev/null +++ b/core/artwork/reader_artist.go @@ -0,0 +1,198 @@ +package artwork + +import ( + "context" + "crypto/md5" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" +) + +const ( + // maxArtistFolderTraversalDepth defines how many directory levels to search + // when looking for artist images (artist folder + parent directories) + maxArtistFolderTraversalDepth = 3 +) + +type artistReader struct { + cacheKey + a *artwork + provider external.Provider + artist model.Artist + artistFolder string + imgFiles []string +} + +func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { + ar, err := artwork.ds.Artist(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + // Only consider albums where the artist is the sole album artist. + als, err := artwork.ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"album_artist_id": artID.ID}, + squirrel.Eq{"json_array_length(participants, '$.albumartist')": 1}, + }, + }) + if err != nil { + return nil, err + } + albumPaths, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, artwork.ds, als...) + if err != nil { + return nil, err + } + artistFolder, artistFolderLastUpdate, err := loadArtistFolder(ctx, artwork.ds, als, albumPaths) + if err != nil { + return nil, err + } + a := &artistReader{ + a: artwork, + provider: provider, + artist: *ar, + artistFolder: artistFolder, + imgFiles: imgFiles, + } + // 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 + + a.cacheKey.lastUpdate = *imagesUpdatedAt + if artistFolderLastUpdate.After(a.cacheKey.lastUpdate) { + a.cacheKey.lastUpdate = artistFolderLastUpdate + } + 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.provider)) + case strings.HasPrefix(pattern, "album/"): + ff = append(ff, fromExternalFile(ctx, a.imgFiles, 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) { + current := artistFolder + for i := 0; i < maxArtistFolderTraversalDepth; i++ { + if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { + return reader, path, nil + } + + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder) + } +} + +func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder) + fsys := os.DirFS(folder) + matches, err := fs.Glob(fsys, pattern) + if err != nil { + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err) + return nil, "", err + } + + // Filter to valid image files + var imagePaths []string + for _, m := range matches { + if !model.IsImageFile(m) { + continue + } + imagePaths = append(imagePaths, m) + } + + // Sort image files by prioritizing base filenames without numeric + // suffixes (e.g., artist.jpg before artist.1.jpg) + slices.SortFunc(imagePaths, compareImageFiles) + + // Try to open files in sorted order + for _, p := range imagePaths { + filePath := filepath.Join(folder, p) + f, err := os.Open(filePath) + if err != nil { + log.Warn(ctx, "Could not open cover art file", "file", filePath, err) + continue + } + return f, filePath, nil + } + + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder) +} + +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 // Just need one of the albums, as they should all be in the same Library - for now! TODO: Support multiple libraries + + folderPath := str.LongestCommonPrefix(paths) + if !strings.HasSuffix(folderPath, string(filepath.Separator)) { + folderPath, _ = filepath.Split(folderPath) + } + folderPath = filepath.Dir(folderPath) + + // Manipulate the path to get the folder ID + // TODO: This is a bit hacky, but it's the easiest way to get the folder ID, ATM + libPath := core.AbsolutePath(ctx, ds, libID, "") + folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath) + + log.Trace(ctx, "Calculating artist folder details", "folderPath", folderPath, "folderID", folderID, + "libPath", libPath, "libID", libID, "albumPaths", paths) + + // Get the last update time for the folder + folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}}) + if err != nil || len(folders) == 0 { + log.Warn(ctx, "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 +} diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go new file mode 100644 index 0000000..e6a0168 --- /dev/null +++ b/core/artwork/reader_artist_test.go @@ -0,0 +1,446 @@ +package artwork + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("artistArtworkReader", func() { + var _ = Describe("loadArtistFolder", func() { + var ( + ctx context.Context + fds *fakeDataStore + repo *fakeFolderRepo + albums model.Albums + paths []string + now time.Time + expectedUpdTime time.Time + ) + + BeforeEach(func() { + ctx = context.Background() + DeferCleanup(stubCoreAbsolutePath()) + + now = time.Now().Truncate(time.Second) + expectedUpdTime = now.Add(5 * time.Minute) + repo = &fakeFolderRepo{ + result: []model.Folder{ + { + ImagesUpdatedAt: expectedUpdTime, + }, + }, + err: nil, + } + fds = &fakeDataStore{ + folderRepo: repo, + } + albums = model.Albums{ + {LibraryID: 1, ID: "album1", Name: "Album 1"}, + } + }) + + When("no albums provided", func() { + It("returns empty and zero time", func() { + folder, upd, err := loadArtistFolder(ctx, fds, model.Albums{}, []string{"/dummy/path"}) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) + }) + + When("artist has only one album", func() { + It("returns the parent folder", func() { + paths = []string{ + filepath.FromSlash("/music/artist/album1"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal("/music/artist")) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + + When("the artist have multiple albums", func() { + It("returns the common prefix for the albums paths", func() { + paths = []string{ + filepath.FromSlash("/music/library/artist/one"), + filepath.FromSlash("/music/library/artist/two"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal(filepath.FromSlash("/music/library/artist"))) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + + When("the album paths contain same prefix", func() { + It("returns the common prefix", func() { + paths = []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + } + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).ToNot(HaveOccurred()) + Expect(folder).To(Equal("/music/artist")) + Expect(upd).To(Equal(expectedUpdTime)) + }) + }) + + When("ds.Folder().GetAll returns an error", func() { + It("returns an error", func() { + paths = []string{ + filepath.FromSlash("/music/artist/album1"), + filepath.FromSlash("/music/artist/album2"), + } + repo.err = errors.New("fake error") + folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) + Expect(err).To(MatchError(ContainSubstring("fake error"))) + // Folder and time are empty on error. + Expect(folder).To(BeEmpty()) + Expect(upd).To(BeZero()) + }) + }) + }) + + var _ = Describe("fromArtistFolder", func() { + var ( + ctx context.Context + tempDir string + testFunc sourceFunc + ) + + BeforeEach(func() { + ctx = context.Background() + tempDir = GinkgoT().TempDir() + }) + + When("artist folder contains matching image", func() { + BeforeEach(func() { + // Create test structure: /temp/artist/artist.jpg + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds and returns the image", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + + // Verify we can read the content + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("fake image data")) + reader.Close() + }) + }) + + When("artist folder is empty but parent contains image", func() { + BeforeEach(func() { + // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ + parentDir := filepath.Join(tempDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + albumDir := filepath.Join(artistDir, "album") + Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) + + // Put artist image in parent directory + artistImagePath := filepath.Join(parentDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds image in parent directory", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("parent" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("parent image")) + reader.Close() + }) + }) + + When("image is two levels up", func() { + BeforeEach(func() { + // Create test structure: /temp/grandparent/artist.jpg and /temp/grandparent/parent/artist/ + grandparentDir := filepath.Join(tempDir, "grandparent") + parentDir := filepath.Join(grandparentDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Put artist image in grandparent directory + artistImagePath := filepath.Join(grandparentDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds image in grandparent directory", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("grandparent" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("grandparent image")) + reader.Close() + }) + }) + + When("images exist at multiple levels", func() { + BeforeEach(func() { + // Create test structure with images at multiple levels + grandparentDir := filepath.Join(tempDir, "grandparent") + parentDir := filepath.Join(grandparentDir, "parent") + artistDir := filepath.Join(parentDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Put artist images at all levels + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist level"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("prioritizes the closest (artist folder) image", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist level")) + reader.Close() + }) + }) + + When("pattern matches multiple files", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create multiple matching files + Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("returns the first valid image file in sorted order", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + + // Should return an image file, + // Files are sorted: jpg comes before png alphabetically. + // .abc comes first, but it's not an image. + Expect(path).To(ContainSubstring("artist.jpg")) + reader.Close() + }) + }) + + When("prioritizing files without numeric suffixes", func() { + BeforeEach(func() { + // Test case for issue #4683: artist.jpg should come before artist.1.jpg + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create multiple matches with and without numeric suffixes + Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + + // Verify it's the main file, not a numbered variant + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist main")) + reader.Close() + }) + }) + + When("handling case-insensitive sorting", func() { + BeforeEach(func() { + // Test case to ensure case-insensitive natural sorting + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create files with mixed case names + Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "*.*") + }) + + It("sorts case-insensitively", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + + // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder") + Expect(path).To(ContainSubstring("artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("artist")) + reader.Close() + }) + }) + + When("no matching files exist anywhere", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create non-matching files + Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("returns an error", func() { + reader, path, err := testFunc() + Expect(err).To(HaveOccurred()) + Expect(reader).To(BeNil()) + Expect(path).To(BeEmpty()) + Expect(err.Error()).To(ContainSubstring("no matches for 'artist.*'")) + Expect(err.Error()).To(ContainSubstring("parent directories")) + }) + }) + + When("directory traversal reaches filesystem root", func() { + BeforeEach(func() { + // Start from a shallow directory to test root boundary + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("handles root boundary gracefully", func() { + reader, path, err := testFunc() + Expect(err).To(HaveOccurred()) + Expect(reader).To(BeNil()) + Expect(path).To(BeEmpty()) + // Should not panic or cause infinite loop + }) + }) + + When("file exists but cannot be opened", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "artist") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + // Create a file that cannot be opened (permission denied) + restrictedFile := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("logs warning and continues searching", func() { + // This test depends on the ability to restrict file permissions + // For now, we'll just ensure it doesn't panic and returns appropriate error + reader, _, err := testFunc() + // The file should be readable in test environment, so this will succeed + // In a real scenario with permission issues, it would continue searching + if err == nil { + Expect(reader).ToNot(BeNil()) + reader.Close() + } + }) + }) + + When("single album artist scenario (original issue)", func() { + BeforeEach(func() { + // Simulate the exact folder structure from the issue: + // /music/artist/album1/ (single album) + // /music/artist/artist.jpg (artist image that should be found) + artistDir := filepath.Join(tempDir, "music", "artist") + albumDir := filepath.Join(artistDir, "album1") + Expect(os.MkdirAll(albumDir, 0755)).To(Succeed()) + + // Create artist.jpg in the artist folder (this was not being found before) + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) + + // The fromArtistFolder is called with the artist folder path + testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + }) + + It("finds artist.jpg in artist folder for single album artist", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("artist.jpg")) + Expect(path).To(ContainSubstring("artist")) + + // Verify the content + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("single album artist image")) + reader.Close() + }) + }) + }) +}) + +type fakeFolderRepo struct { + model.FolderRepository + result []model.Folder + err error +} + +func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { + return f.result, f.err +} + +type fakeDataStore struct { + model.DataStore + folderRepo *fakeFolderRepo +} + +func (fds *fakeDataStore) Folder(_ context.Context) model.FolderRepository { + return fds.folderRepo +} + +func stubCoreAbsolutePath() func() { + // Override core.AbsolutePath to return a fixed string during tests. + original := core.AbsolutePath + core.AbsolutePath = func(_ context.Context, ds model.DataStore, libID int, p string) string { + return filepath.FromSlash("/music") + } + return func() { + core.AbsolutePath = original + } +} diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go new file mode 100644 index 0000000..c72d954 --- /dev/null +++ b/core/artwork/reader_mediafile.go @@ -0,0 +1,65 @@ +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 { + path := a.mediafile.AbsolutePath() + ff = []sourceFunc{ + fromTag(ctx, path), + fromFFmpegTag(ctx, a.a.ffmpeg, 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 0000000..a9f289a --- /dev/null +++ b/core/artwork/reader_playlist.go @@ -0,0 +1,147 @@ +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 toAlbumArtworkIDs(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 := toAlbumArtworkIDs(albumIds) + + var tiles []image.Image + for _, id := range ids { + r, _, err := fromAlbum(ctx, a.a, id)() + if err == nil { + tile, err := a.createTile(ctx, r) + if err == nil { + tiles = append(tiles, tile) + } + _ = r.Close() + } + if len(tiles) == 4 { + break + } + } + 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 0000000..83e6e25 --- /dev/null +++ b/core/artwork/reader_resized.go @@ -0,0 +1,116 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "time" + + "github.com/disintegration/imaging" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type resizedArtworkReader struct { + artID model.ArtworkID + cacheKey string + lastUpdate time.Time + size int + square bool + a *artwork +} + +func resizedFromOriginal(ctx context.Context, a *artwork, artID model.ArtworkID, size int, square bool) (*resizedArtworkReader, error) { + r := &resizedArtworkReader{a: a} + r.artID = artID + r.size = size + r.square = square + + // Get lastUpdated and cacheKey from original artwork + original, err := a.getArtworkReader(ctx, artID, 0, false) + if err != nil { + return nil, err + } + r.cacheKey = original.Key() + r.lastUpdate = original.LastUpdated() + return r, nil +} + +func (a *resizedArtworkReader) Key() string { + baseKey := fmt.Sprintf("%s.%d", a.cacheKey, a.size) + if a.square { + return baseKey + ".square" + } + return fmt.Sprintf("%s.%d", baseKey, 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, false) + if err != nil { + return nil, "", err + } + defer orig.Close() + + resized, origSize, err := resizeImage(orig, a.size, a.square) + if resized == nil { + log.Trace(ctx, "Image smaller than requested size", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) + } else { + log.Trace(ctx, "Resizing artwork", "artID", a.artID, "original", origSize, "resized", a.size, "square", a.square) + } + if err != nil { + log.Warn(ctx, "Could not resize image. Will return image as is", "artID", a.artID, "size", a.size, "square", a.square, err) + } + if err != nil || resized == nil { + // if we couldn't resize the image, return the original + orig, _, err = a.a.Get(ctx, a.artID, 0, false) + return orig, "", err + } + return io.NopCloser(resized), fmt.Sprintf("%s@%d", a.artID, a.size), nil +} + +func resizeImage(reader io.Reader, size int, square bool) (io.Reader, int, error) { + original, format, err := image.Decode(reader) + if err != nil { + return nil, 0, err + } + + bounds := original.Bounds() + originalSize := max(bounds.Max.X, bounds.Max.Y) + + if originalSize <= size && !square { + return nil, originalSize, nil + } + + var resized image.Image + if originalSize >= size { + resized = imaging.Fit(original, size, size, imaging.Lanczos) + } else { + if bounds.Max.Y < bounds.Max.X { + resized = imaging.Resize(original, size, 0, imaging.Lanczos) + } else { + resized = imaging.Resize(original, 0, size, imaging.Lanczos) + } + } + if square { + bg := image.NewRGBA(image.Rect(0, 0, size, size)) + resized = imaging.OverlayCenter(bg, resized, 1) + } + + buf := new(bytes.Buffer) + if format == "png" || square { + err = png.Encode(buf, resized) + } else { + err = jpeg.Encode(buf, resized, &jpeg.Options{Quality: conf.Server.CoverJpegQuality}) + } + return buf, originalSize, err +} diff --git a/core/artwork/sources.go b/core/artwork/sources.go new file mode 100644 index 0000000..4250a37 --- /dev/null +++ b/core/artwork/sources.go @@ -0,0 +1,194 @@ +package artwork + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "reflect" + "regexp" + "runtime" + "strings" + "time" + + "github.com/dhowden/tag" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" +) + +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 `%s` cover art for %s: %w", artID.Kind, artID, ErrUnavailable) +} + +type sourceFunc func() (r io.ReadCloser, path string, err error) + +func (f sourceFunc) String() string { + name := runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() + name = strings.TrimPrefix(name, "github.com/navidrome/navidrome/core/artwork.") + if _, after, found := strings.Cut(name, ")."); found { + name = after + } + name = strings.TrimSuffix(name, ".func1") + return name +} + +func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc { + return func() (io.ReadCloser, string, error) { + for _, file := range files { + _, name := filepath.Split(file) + match, err := filepath.Match(pattern, strings.ToLower(name)) + if err != nil { + log.Warn(ctx, "Error matching cover art file to pattern", "pattern", pattern, "file", file) + continue + } + if !match { + continue + } + f, err := os.Open(file) + if err != nil { + log.Warn(ctx, "Could not open cover art file", "file", file, err) + continue + } + 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, path string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if path == "" { + return nil, "", nil + } + f, err := os.Open(path) + if err != nil { + return nil, "", err + } + defer f.Close() + + m, err := tag.ReadFrom(f) + if err != nil { + return nil, "", err + } + + types := m.PictureTypes() + if len(types) == 0 { + return nil, "", fmt.Errorf("no embedded image found in %s", path) + } + + var picture *tag.Picture + for _, regex := range picTypeRegexes { + for _, t := range types { + if regex.MatchString(t) { + log.Trace(ctx, "Found embedded image", "type", t, "path", path) + picture = m.Pictures(t) + break + } + } + if picture != nil { + break + } + } + if picture == nil { + log.Trace(ctx, "Could not find a front image. Getting the first one", "type", types[0], "path", path) + picture = m.Picture() + } + if picture == nil { + return nil, "", fmt.Errorf("could not load embedded image from %s", path) + } + return io.NopCloser(bytes.NewReader(picture.Data)), path, nil + } +} + +func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc { + return func() (io.ReadCloser, string, error) { + if path == "" { + return nil, "", nil + } + r, err := ffmpeg.ExtractImage(ctx, path) + if err != nil { + return nil, "", err + } + return r, path, nil + } +} + +func fromAlbum(ctx context.Context, a *artwork, id model.ArtworkID) sourceFunc { + return func() (io.ReadCloser, string, error) { + r, _, err := a.Get(ctx, id, 0, false) + if err != nil { + return nil, "", err + } + return r, id.String(), nil + } +} + +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, provider external.Provider) sourceFunc { + return func() (io.ReadCloser, string, error) { + imageUrl, err := provider.ArtistImage(ctx, ar.ID) + if err != nil { + return nil, "", err + } + + return fromURL(ctx, imageUrl) + } +} + +func fromAlbumExternalSource(ctx context.Context, al model.Album, provider external.Provider) sourceFunc { + return func() (io.ReadCloser, string, error) { + imageUrl, err := provider.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 := hc.Do(req) + if err != nil { + return nil, "", err + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + return nil, "", fmt.Errorf("error retrieving artwork from %s: %s", imageUrl, resp.Status) + } + return resp.Body, imageUrl.String(), nil +} diff --git a/core/artwork/wire_providers.go b/core/artwork/wire_providers.go new file mode 100644 index 0000000..63231b5 --- /dev/null +++ b/core/artwork/wire_providers.go @@ -0,0 +1,11 @@ +package artwork + +import ( + "github.com/google/wire" +) + +var Set = wire.NewSet( + NewArtwork, + GetImageCache, + NewCacheWarmer, +) diff --git a/core/auth/auth.go b/core/auth/auth.go new file mode 100644 index 0000000..ddd1276 --- /dev/null +++ b/core/auth/auth.go @@ -0,0 +1,147 @@ +package auth + +import ( + "cmp" + "context" + "crypto/sha256" + "sync" + "time" + + "github.com/go-chi/jwtauth/v5" + "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 *jwtauth.JWTAuth +) + +// Init creates a JWTAuth object from the secret stored in the DB. +// If the secret is not found, it will create a new one and store it in the DB. +func Init(ds model.DataStore) { + once.Do(func() { + ctx := context.TODO() + log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout) + + secret, err := ds.Property(ctx).Get(consts.JWTSecretKey) + if err != nil || secret == "" { + log.Info(ctx, "Creating new JWT secret, used for encrypting UI sessions") + secret = createNewSecret(ctx, ds) + } else { + if secret, err = utils.Decrypt(ctx, getEncKey(), secret); err != nil { + log.Error(ctx, "Could not decrypt JWT secret, creating a new one", err) + secret = createNewSecret(ctx, ds) + } + } + + TokenAuth = jwtauth.New("HS256", []byte(secret), nil) + }) +} + +func createBaseClaims() map[string]any { + tokenClaims := map[string]any{} + tokenClaims[jwt.IssuerKey] = consts.JWTIssuer + return tokenClaims +} + +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 map[string]any) (string, error) { + tokenClaims := createBaseClaims() + if !exp.IsZero() { + tokenClaims[jwt.ExpirationKey] = exp.UTC().Unix() + } + for k, v := range claims { + tokenClaims[k] = v + } + _, token, err := TokenAuth.Encode(tokenClaims) + + return token, err +} + +func CreateToken(u *model.User) (string, error) { + 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 + } + + return TouchToken(token) +} + +func TouchToken(token jwt.Token) (string, error) { + 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) + + return newToken, err +} + +func Validate(tokenStr string) (map[string]interface{}, error) { + token, err := jwtauth.VerifyToken(TokenAuth, tokenStr) + if err != nil { + return nil, err + } + return token.AsMap(context.Background()) +} + +func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { + u, err := ds.User(ctx).FindFirstAdmin() + if err != nil { + c, err := ds.User(ctx).CountAll() + if c == 0 && err == nil { + log.Debug(ctx, "No admin user yet!", err) + } else { + log.Error(ctx, "No admin user found!", err) + } + u = &model.User{} + } + + ctx = request.WithUsername(ctx, u.UserName) + return request.WithUser(ctx, *u) +} + +func createNewSecret(ctx context.Context, ds model.DataStore) 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(ctx).Put(consts.JWTSecretKey, encSecret); err != nil { + log.Error(ctx, "Could not save JWT secret in DB", err) + } + return secret +} + +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 new file mode 100644 index 0000000..504e56a --- /dev/null +++ b/core/auth/auth_test.go @@ -0,0 +1,111 @@ +package auth_test + +import ( + "testing" + "time" + + "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" +) + +func TestAuth(t *testing.T) { + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Auth Test Suite") +} + +const ( + testJWTSecret = "not so secret" + oneDay = 24 * time.Hour +) + +var _ = BeforeSuite(func() { + conf.Server.SessionTimeout = 2 * oneDay +}) + +var _ = Describe("Auth", func() { + + BeforeEach(func() { + ds := &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + } + auth.Init(ds) + }) + + Describe("Validate", func() { + It("returns error with an invalid JWT token", func() { + _, err := auth.Validate("invalid.token") + Expect(err).To(HaveOccurred()) + }) + + It("returns the claims from a valid JWT token", func() { + claims := map[string]interface{}{} + claims["iss"] = "issuer" + claims["iat"] = time.Now().Unix() + claims["exp"] = time.Now().Add(1 * time.Minute).Unix() + _, tokenStr, err := auth.TokenAuth.Encode(claims) + Expect(err).NotTo(HaveOccurred()) + + decodedClaims, err := auth.Validate(tokenStr) + Expect(err).NotTo(HaveOccurred()) + Expect(decodedClaims["iss"]).To(Equal("issuer")) + }) + + It("returns ErrExpired if the `exp` field is in the past", func() { + claims := map[string]interface{}{} + claims["iss"] = "issuer" + claims["exp"] = time.Now().Add(-1 * time.Minute).Unix() + _, tokenStr, err := auth.TokenAuth.Encode(claims) + Expect(err).NotTo(HaveOccurred()) + + _, err = auth.Validate(tokenStr) + Expect(err).To(MatchError("token is expired")) + }) + }) + + Describe("CreateToken", func() { + It("creates a valid token", func() { + u := &model.User{ + ID: "123", + UserName: "johndoe", + IsAdmin: true, + } + tokenStr, err := auth.CreateToken(u) + Expect(err).NotTo(HaveOccurred()) + + claims, err := auth.Validate(tokenStr) + Expect(err).NotTo(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]interface{}{} + claims["iss"] = "issuer" + claims["exp"] = yesterday.Unix() + token, _, err := auth.TokenAuth.Encode(claims) + Expect(err).NotTo(HaveOccurred()) + + touched, err := auth.TouchToken(token) + Expect(err).NotTo(HaveOccurred()) + + decodedClaims, err := auth.Validate(touched) + Expect(err).NotTo(HaveOccurred()) + exp := decodedClaims["exp"].(time.Time) + Expect(exp.Sub(yesterday)).To(BeNumerically(">=", oneDay)) + }) + }) +}) diff --git a/core/common.go b/core/common.go new file mode 100644 index 0000000..6ff349b --- /dev/null +++ b/core/common.go @@ -0,0 +1,27 @@ +package core + +import ( + "context" + "path/filepath" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +func userName(ctx context.Context) string { + if user, ok := request.UserFrom(ctx); !ok { + return "UNKNOWN" + } else { + 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(ctx).GetPath(libId) + if err != nil { + return path + } + return filepath.Join(libPath, path) +} diff --git a/core/common_test.go b/core/common_test.go new file mode 100644 index 0000000..c8dde12 --- /dev/null +++ b/core/common_test.go @@ -0,0 +1,55 @@ +package core + +import ( + "context" + + . "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("/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/core_suite_test.go b/core/core_suite_test.go new file mode 100644 index 0000000..afa5fd9 --- /dev/null +++ b/core/core_suite_test.go @@ -0,0 +1,17 @@ +package core + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCore(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Core Suite") +} diff --git a/core/external/extdata_helper_test.go b/core/external/extdata_helper_test.go new file mode 100644 index 0000000..29975e5 --- /dev/null +++ b/core/external/extdata_helper_test.go @@ -0,0 +1,284 @@ +package external_test + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/model" + "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(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(options ...model.QueryOptions) (model.Artists, error) { + argsSlice := make([]interface{}, 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(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) +} + +// GetAll implements model.MediaFileRepository. +func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + argsSlice := make([]interface{}, 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(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(options ...model.QueryOptions) (model.Albums, error) { + argsSlice := make([]interface{}, 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) +} diff --git a/core/external/extdata_suite_test.go b/core/external/extdata_suite_test.go new file mode 100644 index 0000000..f059e76 --- /dev/null +++ b/core/external/extdata_suite_test.go @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..413c7e0 --- /dev/null +++ b/core/external/provider.go @@ -0,0 +1,733 @@ +package external + +import ( + "context" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + _ "github.com/navidrome/navidrome/core/agents/deezer" + _ "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/gg" + "github.com/navidrome/navidrome/utils/random" + "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) + ArtistRadio(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 provider struct { + ds model.DataStore + ag Agents + 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 +} + +func NewProvider(ds model.DataStore, agents Agents) Provider { + e := &provider{ds: ds, ag: agents} + e.artistQueue = newRefreshQueue(context.TODO(), e.populateArtistInfo) + e.albumQueue = newRefreshQueue(context.TODO(), e.populateAlbumInfo) + return e +} + +func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) { + var entity interface{} + 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) + if errors.Is(err, agents.ErrNotFound) { + 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 = P(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(ctx).UpdateExternalInfo(&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)) + } + + return album, nil +} + +func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error) { + var entity interface{} + 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() + if artist.MbzArtistID == "" { + mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName) + 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", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err()) + return artist, ctx.Err() + } + + artist.ExternalInfoUpdatedAt = P(time.Now()) + err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) + if err != nil { + log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, + "elapsed", time.Since(start), err) + } else { + log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start)) + } + return artist, nil +} + +func (e *provider) ArtistRadio(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, "ArtistRadio call canceled", ctx.Err()) + return nil, ctx.Err() + } + + weightedSongs := random.NewWeightedChooser[model.MediaFile]() + addArtist := func(a model.Artist, weightedSongs *random.WeightedChooser[model.MediaFile], count, artistWeight int) error { + if utils.IsCtxDone(ctx) { + log.Warn(ctx, "ArtistRadio call canceled", ctx.Err()) + 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 + } + } + + var similarSongs model.MediaFiles + for len(similarSongs) < count && weightedSongs.Size() > 0 { + s, err := weightedSongs.Pick() + if err != nil { + log.Warn(ctx, "Error getting weighted song", err) + continue + } + similarSongs = append(similarSongs, s) + } + + return similarSongs, nil +} + +func (e *provider) 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, model.ErrNotFound + } + return url.Parse(imageUrl) +} + +func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error) { + album, err := e.getAlbum(ctx, id) + if err != nil { + return nil, err + } + + albumName := album.Name() + images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID) + if err != nil { + switch { + case errors.Is(err, agents.ErrNotFound): + log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist) + return nil, model.ErrNotFound + case errors.Is(err, context.Canceled): + log.Debug(ctx, "GetAlbumImages call canceled", err) + default: + log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err) + } + return nil, err + } + + if len(images) == 0 { + log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist) + return nil, model.ErrNotFound + } + + // Return the biggest image + var img agents.ExternalImage + for _, i := range images { + if img.Size <= i.Size { + img = i + } + } + if img.URL == "" { + return nil, model.ErrNotFound + } + return url.Parse(img.URL) +} + +func (e *provider) 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 + } + + songs, err := e.getMatchingTopSongs(ctx, e.ag, artist, count) + if err != nil { + switch { + case errors.Is(err, agents.ErrNotFound): + log.Trace(ctx, "TopSongs not found", "name", artistName) + 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) + } + + mbidMatches, err := e.loadTracksByMBID(ctx, songs) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) + } + titleMatches, err := e.loadTracksByTitle(ctx, songs, artist, mbidMatches) + if err != nil { + return nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + + log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches)) + mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count) + + 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 +} + +func (e *provider) loadTracksByMBID(ctx context.Context, songs []agents.Song) (map[string]model.MediaFile, error) { + var mbids []string + for _, s := range songs { + if s.MBID != "" { + mbids = append(mbids, s.MBID) + } + } + matches := map[string]model.MediaFile{} + if len(mbids) == 0 { + return matches, nil + } + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Eq{"mbz_recording_id": mbids}, + squirrel.Eq{"missing": false}, + }, + }) + if err != nil { + return matches, err + } + for _, mf := range res { + if id := mf.MbzRecordingID; id != "" { + if _, ok := matches[id]; !ok { + matches[id] = mf + } + } + } + return matches, nil +} + +func (e *provider) loadTracksByTitle(ctx context.Context, songs []agents.Song, artist *auxArtist, mbidMatches map[string]model.MediaFile) (map[string]model.MediaFile, error) { + titleMap := map[string]string{} + for _, s := range songs { + if s.MBID != "" && mbidMatches[s.MBID].ID != "" { + continue + } + sanitized := str.SanitizeFieldForSorting(s.Name) + titleMap[sanitized] = s.Name + } + matches := map[string]model.MediaFile{} + if len(titleMap) == 0 { + return matches, nil + } + titleFilters := squirrel.Or{} + for sanitized := range titleMap { + titleFilters = append(titleFilters, squirrel.Like{"order_title": sanitized}) + } + + res, err := e.ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.And{ + squirrel.Or{ + squirrel.Eq{"artist_id": artist.ID}, + squirrel.Eq{"album_artist_id": artist.ID}, + }, + titleFilters, + squirrel.Eq{"missing": false}, + }, + Sort: "starred desc, rating desc, year asc, compilation asc ", + }) + if err != nil { + return matches, err + } + for _, mf := range res { + sanitized := str.SanitizeFieldForSorting(mf.Title) + if _, ok := matches[sanitized]; !ok { + matches[sanitized] = mf + } + } + return matches, nil +} + +func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[string]model.MediaFile, count int) model.MediaFiles { + var mfs model.MediaFiles + for _, t := range songs { + if len(mfs) == count { + break + } + if t.MBID != "" { + if mf, ok := byMBID[t.MBID]; ok { + mfs = append(mfs, mf) + continue + } + } + if mf, ok := byTitle[str.SanitizeFieldForSorting(t.Name)]; ok { + mfs = append(mfs, mf) + } + } + return mfs +} + +func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) { + artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID) + if err != nil { + return + } + artist.ExternalUrl = artisURL +} + +func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) { + bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID) + if err != nil { + return + } + 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 + } +} + +func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist, + limit int, includeNotPresent bool) { + artistName := artist.Name() + similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit) + if len(similar) == 0 || err != nil { + return + } + 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 + } + artist.SimilarArtists = sa +} + +func (e *provider) mapSimilarArtists(ctx context.Context, similar []agents.Artist, limit int, includeNotPresent bool) (model.Artists, error) { + var result model.Artists + var notPresent []string + + artistNames := slice.Map(similar, func(artist agents.Artist) string { return artist.Name }) + + // Query all artists at once + clauses := slice.Map(artistNames, func(name string) squirrel.Sqlizer { + return squirrel.Like{"artist.name": name} + }) + artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Or(clauses), + }) + if err != nil { + return nil, err + } + + // Create a map for quick lookup + artistMap := make(map[string]model.Artist) + for _, artist := range artists { + artistMap[artist.Name] = artist + } + + count := 0 + + // Process the similar artists + for _, s := range similar { + if artist, found := artistMap[s.Name]; found { + result = append(result, artist) + count++ + + if count >= limit { + break + } + } 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) 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 + } + 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(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 = "" + } + 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_albumimage_test.go b/core/external/provider_albumimage_test.go new file mode 100644 index 0000000..8a81b4f --- /dev/null +++ b/core/external/provider_albumimage_test.go @@ -0,0 +1,364 @@ +package external_test + +import ( + "context" + "errors" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - AlbumImage", func() { + var ds *tests.MockDataStore + var provider Provider + var mockArtistRepo *mockArtistRepo + var mockAlbumRepo *mockAlbumRepo + var mockMediaFileRepo *mockMediaFileRepo + var mockAlbumAgent *mockAlbumInfoAgent + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + DeferCleanup(configtest.SetupConfig()) + conf.Server.Agents = "mockAlbum" // Configure mock agent + + mockArtistRepo = newMockArtistRepo() + mockAlbumRepo = newMockAlbumRepo() + mockMediaFileRepo = newMockMediaFileRepo() + + ds = &tests.MockDataStore{ + MockedArtist: mockArtistRepo, + MockedAlbum: mockAlbumRepo, + MockedMediaFile: mockMediaFileRepo, + } + + mockAlbumAgent = newMockAlbumInfoAgent() + + agentsCombined := &mockAgents{albumInfoAgent: mockAlbumAgent} + provider = NewProvider(ds, agentsCombined) + + // Default mocks + // Mocks for GetEntityByID sequence (initial failed lookups) + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + + // Default mock for non-existent entities - Use Maybe() for flexibility + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + }) + + It("returns the largest image URL when successful", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/large.jpg") + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // From GetEntityByID + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") // Artist lookup no longer happens in getAlbum + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist name + }) + + It("returns ErrNotFound if the album is not found in the DB", func() { + // Arrange: Explicitly expect the full GetEntityByID sequence for "not-found" + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + + imgURL, err := provider.AlbumImage(ctx, "not-found") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) + }) + + It("returns the agent error if the agent fails", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + agentErr := errors.New("agent failure") + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agentErr).Once() // Expect empty artist + + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).To(MatchError("agent failure")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist + }) + + It("returns ErrNotFound if the agent returns ErrNotFound", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", "").Return(nil, agents.ErrNotFound).Once() // Expect empty artist + + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist + }) + + It("returns ErrNotFound if the agent returns no images", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{}, nil).Once() // Expect empty artist + + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") // Expect empty artist + }) + + It("returns context error if context is canceled", func() { + // Arrange + cctx, cancelCtx := context.WithCancel(ctx) + // Mock the necessary DB calls *before* canceling the context + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Expect the agent call even if context is cancelled, returning the context error + mockAlbumAgent.On("GetAlbumImages", cctx, "Album One", "", "").Return(nil, context.Canceled).Once() + // Cancel the context *before* calling the function under test + cancelCtx() + + imgURL, err := provider.AlbumImage(cctx, "album-1") + + Expect(err).To(MatchError("context canceled")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + // Agent should now be called, verify this expectation + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", cctx, "Album One", "", "") + }) + + It("derives album ID from MediaFile ID", func() { + // Arrange: Mock full GetEntityByID for "mf-1" and recursive "album-1" + mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1", AlbumID: "album-1"}, nil).Once() + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/large.jpg") + imgURL, err := provider.AlbumImage(ctx, "mf-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertNotCalled(GinkgoT(), "Get", "artist-1") + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") + }) + + It("handles different image orders from agent", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/small.jpg", Size: 200}, + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/large.jpg") + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) // Should still pick the largest + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") + }) + + It("handles agent returning only one image", func() { + // Arrange + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() // Expect GetEntityByID sequence + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Once() + // Explicitly mock agent call for this test + mockAlbumAgent.On("GetAlbumImages", ctx, "Album One", "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/single.jpg", Size: 700}, + }, nil).Once() + + expectedURL, _ := url.Parse("http://example.com/single.jpg") + imgURL, err := provider.AlbumImage(ctx, "album-1") + + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, "Album One", "", "") + }) + + It("returns ErrNotFound if deriving album ID fails", func() { + // Arrange: Mock full GetEntityByID for "mf-no-album" and recursive "not-found" + mockArtistRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-no-album").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "mf-no-album").Return(&model.MediaFile{ID: "mf-no-album", Title: "Track No Album", ArtistID: "artist-1", AlbumID: "not-found"}, nil).Once() + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Once() + + imgURL, err := provider.AlbumImage(ctx, "mf-no-album") + + Expect(err).To(MatchError("data not found")) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-no-album") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything) + }) + + Context("Unicode handling in album names", func() { + var albumWithEnDash *model.Album + var expectedURL *url.URL + + const ( + originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash + normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen + ) + + BeforeEach(func() { + // Test with en dash (–) in album name + albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"} + mockArtistRepo.Mock = mock.Mock{} // Reset default expectations + mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations + mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once() + + expectedURL, _ = url.Parse("http://example.com/album.jpg") + + // Mock the album agent to return an image for the album + mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/album.jpg", Size: 1000}, + }, nil).Once() + }) + + When("DevPreserveUnicodeInExternalCalls is true", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = true + }) + + It("preserves Unicode characters in album names", func() { + // Act + imgURL, err := provider.AlbumImage(ctx, "album-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") + // This is the key assertion: ensure the original Unicode name is used + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "") + }) + }) + + When("DevPreserveUnicodeInExternalCalls is false", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = false + }) + + It("normalizes Unicode characters", func() { + // Act + imgURL, err := provider.AlbumImage(ctx, "album-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash") + // This assertion ensures the normalized name is used (en dash → hyphen) + mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "") + }) + }) + }) +}) + +// mockAlbumInfoAgent implementation +type mockAlbumInfoAgent struct { + mock.Mock + agents.AlbumInfoRetriever + agents.AlbumImageRetriever +} + +func newMockAlbumInfoAgent() *mockAlbumInfoAgent { + m := new(mockAlbumInfoAgent) + m.On("AgentName").Return("mockAlbum").Maybe() + return m +} + +func (m *mockAlbumInfoAgent) AgentName() string { + args := m.Called() + return args.String(0) +} + +func (m *mockAlbumInfoAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { + args := m.Called(ctx, name, artist, mbid) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*agents.AlbumInfo), args.Error(1) +} + +func (m *mockAlbumInfoAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { + args := m.Called(ctx, name, artist, mbid) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]agents.ExternalImage), args.Error(1) +} + +// Ensure mockAgent implements the interfaces +var _ agents.AlbumInfoRetriever = (*mockAlbumInfoAgent)(nil) +var _ agents.AlbumImageRetriever = (*mockAlbumInfoAgent)(nil) diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go new file mode 100644 index 0000000..11290bb --- /dev/null +++ b/core/external/provider_artistimage_test.go @@ -0,0 +1,362 @@ +package external_test + +import ( + "context" + "errors" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/agents" + . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - ArtistImage", func() { + var ds *tests.MockDataStore + var provider Provider + var mockArtistRepo *mockArtistRepo + var mockAlbumRepo *mockAlbumRepo + var mockMediaFileRepo *mockMediaFileRepo + var mockImageAgent *mockArtistImageAgent + var agentsCombined *mockAgents + var ctx context.Context + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Agents = "mockImage" // Configure only the mock agent + ctx = GinkgoT().Context() + + mockArtistRepo = newMockArtistRepo() + mockAlbumRepo = newMockAlbumRepo() + mockMediaFileRepo = newMockMediaFileRepo() + + ds = &tests.MockDataStore{ + MockedArtist: mockArtistRepo, + MockedAlbum: mockAlbumRepo, + MockedMediaFile: mockMediaFileRepo, + } + + mockImageAgent = newMockArtistImageAgent() + + // Use the mockAgents from helper, setting the specific agent + agentsCombined = &mockAgents{ + imageAgent: mockImageAgent, + } + + provider = NewProvider(ds, agentsCombined) + + // Default mocks for successful Get calls + mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Maybe() + mockAlbumRepo.On("Get", "album-1").Return(&model.Album{ID: "album-1", Name: "Album One", AlbumArtistID: "artist-1"}, nil).Maybe() + mockMediaFileRepo.On("Get", "mf-1").Return(&model.MediaFile{ID: "mf-1", Title: "Track One", ArtistID: "artist-1"}, nil).Maybe() + // Default mock for non-existent entities + mockArtistRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockAlbumRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + + // Default successful image agent response + mockImageAgent.On("GetArtistImages", mock.Anything, "artist-1", "Artist One", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + {URL: "http://example.com/small.jpg", Size: 200}, + }, nil).Maybe() + }) + + AfterEach(func() { + mockArtistRepo.AssertExpectations(GinkgoT()) + mockAlbumRepo.AssertExpectations(GinkgoT()) + mockMediaFileRepo.AssertExpectations(GinkgoT()) + mockImageAgent.AssertExpectations(GinkgoT()) + }) + + It("returns the largest image URL when successful", func() { + // Arrange + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if the artist is not found in the DB", func() { + // Arrange + + // Act + imgURL, err := provider.ArtistImage(ctx, "not-found") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + It("returns the agent error if the agent fails", func() { + // Arrange + agentErr := errors.New("agent failure") + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agentErr).Once() + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) // Corrected Expectation: The provider maps agent errors (other than canceled) to ErrNotFound if no image was found/populated + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if the agent returns ErrNotFound", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return(nil, agents.ErrNotFound).Once() + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if the agent returns no images", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", "").Return([]agents.ExternalImage{}, nil).Once() + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) // Implementation maps empty result to ErrNotFound + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns context error if context is canceled before agent call", func() { + // Arrange + cctx, cancelCtx := context.WithCancel(context.Background()) + mockArtistRepo.Mock = mock.Mock{} // Reset default expectation for artist repo as well + mockArtistRepo.On("Get", "artist-1").Return(&model.Artist{ID: "artist-1", Name: "Artist One"}, nil).Run(func(args mock.Arguments) { + cancelCtx() // Cancel context *during* the DB call simulation + }).Once() + + // Act + imgURL, err := provider.ArtistImage(cctx, "artist-1") + + // Assert + Expect(err).To(MatchError(context.Canceled)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + }) + + It("derives artist ID from MediaFile ID", func() { + // Arrange: Add mocks for the initial GetEntityByID lookups + mockArtistRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-1").Return(nil, model.ErrNotFound).Once() + // Default mocks for MediaFileRepo.Get("mf-1") and ArtistRepo.Get("artist-1") handle the rest + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "mf-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-1") // GetEntityByID sequence + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-1") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting MF + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("derives artist ID from Album ID", func() { + // Arrange: Add mock for the initial GetEntityByID lookup + mockArtistRepo.On("Get", "album-1").Return(nil, model.ErrNotFound).Once() + // Default mocks for AlbumRepo.Get("album-1") and ArtistRepo.Get("artist-1") handle the rest + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "album-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "album-1") // GetEntityByID sequence + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-1") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") // Should be called after getting Album + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("returns ErrNotFound if derived artist is not found", func() { + // Arrange + // Add mocks for the initial GetEntityByID lookups + mockArtistRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() + mockAlbumRepo.On("Get", "mf-bad-artist").Return(nil, model.ErrNotFound).Once() + mockMediaFileRepo.On("Get", "mf-bad-artist").Return(&model.MediaFile{ID: "mf-bad-artist", ArtistID: "not-found"}, nil).Once() + // Add expectation for the recursive GetEntityByID call for the MediaFileRepo + mockMediaFileRepo.On("Get", "not-found").Return(nil, model.ErrNotFound).Maybe() + // The default mocks for ArtistRepo/AlbumRepo handle the final "not-found" lookups + + // Act + imgURL, err := provider.ArtistImage(ctx, "mf-bad-artist") + + // Assert + Expect(err).To(MatchError(model.ErrNotFound)) + Expect(imgURL).To(BeNil()) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence + mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") // GetEntityByID sequence + mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "mf-bad-artist") + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "not-found") + mockImageAgent.AssertNotCalled(GinkgoT(), "GetArtistImages", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }) + + It("handles different image orders from agent", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/small.jpg", Size: 200}, + {URL: "http://example.com/large.jpg", Size: 1000}, + {URL: "http://example.com/medium.jpg", Size: 500}, + }, nil).Once() + expectedURL, _ := url.Parse("http://example.com/large.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) // Still picks the largest + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + It("handles agent returning only one image", func() { + // Arrange + mockImageAgent.Mock = mock.Mock{} // Reset default expectation + mockImageAgent.On("GetArtistImages", ctx, "artist-1", "Artist One", ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/medium.jpg", Size: 500}, + }, nil).Once() + expectedURL, _ := url.Parse("http://example.com/medium.jpg") + + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-1") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1") + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "") + }) + + Context("Unicode handling in artist names", func() { + var artistWithEnDash *model.Artist + var expectedURL *url.URL + + const ( + originalArtistName = "Run–D.M.C." // Artist name with en dash + normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen + ) + + BeforeEach(func() { + // Test with en dash (–) in artist name like "Run–D.M.C." + artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName} + mockArtistRepo.Mock = mock.Mock{} // Reset default expectations + mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once() + + expectedURL, _ = url.Parse("http://example.com/rundmc.jpg") + + // Mock the image agent to return an image for the artist + mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), ""). + Return([]agents.ExternalImage{ + {URL: "http://example.com/rundmc.jpg", Size: 1000}, + }, nil).Once() + + }) + + When("DevPreserveUnicodeInExternalCalls is true", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = true + }) + It("preserves Unicode characters in artist names", func() { + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") + // This is the key assertion: ensure the original Unicode name is used + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "") + }) + }) + + When("DevPreserveUnicodeInExternalCalls is false", func() { + BeforeEach(func() { + conf.Server.DevPreserveUnicodeInExternalCalls = false + }) + + It("normalizes Unicode characters", func() { + // Act + imgURL, err := provider.ArtistImage(ctx, "artist-endash") + + // Assert + Expect(err).ToNot(HaveOccurred()) + Expect(imgURL).To(Equal(expectedURL)) + mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash") + // This assertion ensures the normalized name is used (en dash → hyphen) + mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "") + }) + }) + }) +}) + +// mockArtistImageAgent implementation using testify/mock +// This remains local as it's specific to testing the ArtistImage functionality +type mockArtistImageAgent struct { + mock.Mock + agents.ArtistImageRetriever // Embed interface +} + +// Constructor for the mock agent +func newMockArtistImageAgent() *mockArtistImageAgent { + mock := new(mockArtistImageAgent) + // Set default AgentName if needed, although usually called via mockAgents + mock.On("AgentName").Return("mockImage").Maybe() + return mock +} + +func (m *mockArtistImageAgent) AgentName() string { + args := m.Called() + return args.String(0) +} + +func (m *mockArtistImageAgent) GetArtistImages(ctx context.Context, id, artistName, mbid string) ([]agents.ExternalImage, error) { + args := m.Called(ctx, id, artistName, mbid) + // Need careful type assertion for potentially nil slice + var res []agents.ExternalImage + if args.Get(0) != nil { + res = args.Get(0).([]agents.ExternalImage) + } + return res, args.Error(1) +} + +// Ensure mockAgent implements the interface +var _ agents.ArtistImageRetriever = (*mockArtistImageAgent)(nil) diff --git a/core/external/provider_artistradio_test.go b/core/external/provider_artistradio_test.go new file mode 100644 index 0000000..21ea077 --- /dev/null +++ b/core/external/provider_artistradio_test.go @@ -0,0 +1,196 @@ +package external_test + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/agents" + . "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - ArtistRadio", func() { + var ds model.DataStore + var provider Provider + var mockAgent *mockSimilarArtistAgent + var mockTopAgent agents.ArtistTopSongsRetriever + var mockSimilarAgent agents.ArtistSimilarRetriever + var agentsCombined Agents + var artistRepo *mockArtistRepo + var mediaFileRepo *mockMediaFileRepo + var ctx context.Context + + BeforeEach(func() { + ctx = GinkgoT().Context() + + artistRepo = newMockArtistRepo() + mediaFileRepo = newMockMediaFileRepo() + + ds = &tests.MockDataStore{ + MockedArtist: artistRepo, + MockedMediaFile: mediaFileRepo, + } + + mockAgent = &mockSimilarArtistAgent{} + mockTopAgent = mockAgent + mockSimilarAgent = mockAgent + + agentsCombined = &mockAgents{ + topSongsAgent: mockTopAgent, + similarAgent: mockSimilarAgent, + } + + provider = NewProvider(ds, agentsCombined) + }) + + It("returns similar songs from main artist and similar artists", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + similarArtist := model.Artist{ID: "artist-3", Name: "Similar Artist"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} + song3 := model.MediaFile{ID: "song-3", Title: "Song Three", ArtistID: "artist-3", MbzRecordingID: "mbid-3"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("Get", "artist-3").Return(&similarArtist, nil).Maybe() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Once() + + similarAgentsResp := []agents.Artist{ + {Name: "Similar Artist", MBID: "similar-mbid"}, + } + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return(similarAgentsResp, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{similarArtist}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song One", MBID: "mbid-1"}, + {Name: "Song Two", MBID: "mbid-2"}, + }, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-3", "Similar Artist", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song Three", MBID: "mbid-3"}, + }, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song3}, nil).Once() + + songs, err := provider.ArtistRadio(ctx, "artist-1", 3) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(3)) + for _, song := range songs { + Expect(song.ID).To(BeElementOf("song-1", "song-2", "song-3")) + } + }) + + It("returns ErrNotFound when artist is not found", func() { + artistRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) + mediaFileRepo.On("Get", "artist-unknown-artist").Return(nil, model.ErrNotFound) + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{}, nil).Maybe() + + songs, err := provider.ArtistRadio(ctx, "artist-unknown-artist", 5) + + Expect(err).To(Equal(model.ErrNotFound)) + Expect(songs).To(BeNil()) + }) + + It("returns songs from main artist when GetSimilarArtists returns error", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return(nil, errors.New("error getting similar artists")).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song One", MBID: "mbid-1"}, + }, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1}, nil).Once() + + songs, err := provider.ArtistRadio(ctx, "artist-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(Equal("song-1")) + }) + + It("returns empty list when GetArtistTopSongs returns error", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return([]agents.Artist{}, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return(nil, errors.New("error getting top songs")).Once() + + songs, err := provider.ArtistRadio(ctx, "artist-1", 5) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(BeEmpty()) + }) + + It("respects count parameter", func() { + artist1 := model.Artist{ID: "artist-1", Name: "Artist One"} + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "mbid-1"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "mbid-2"} + + artistRepo.On("Get", "artist-1").Return(&artist1, nil).Maybe() + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 1 && opt.Filters != nil + })).Return(model.Artists{artist1}, nil).Maybe() + + mockAgent.On("GetSimilarArtists", mock.Anything, "artist-1", "Artist One", "", 15). + Return([]agents.Artist{}, nil).Once() + + artistRepo.On("GetAll", mock.MatchedBy(func(opt model.QueryOptions) bool { + return opt.Max == 0 && opt.Filters != nil + })).Return(model.Artists{}, nil).Once() + + mockAgent.On("GetArtistTopSongs", mock.Anything, "artist-1", "Artist One", "", mock.Anything). + Return([]agents.Song{ + {Name: "Song One", MBID: "mbid-1"}, + {Name: "Song Two", MBID: "mbid-2"}, + }, nil).Once() + + mediaFileRepo.On("GetAll", mock.AnythingOfType("model.QueryOptions")).Return(model.MediaFiles{song1, song2}, nil).Once() + + songs, err := provider.ArtistRadio(ctx, "artist-1", 1) + + Expect(err).ToNot(HaveOccurred()) + Expect(songs).To(HaveLen(1)) + Expect(songs[0].ID).To(BeElementOf("song-1", "song-2")) + }) +}) diff --git a/core/external/provider_topsongs_test.go b/core/external/provider_topsongs_test.go new file mode 100644 index 0000000..5a5a257 --- /dev/null +++ b/core/external/provider_topsongs_test.go @@ -0,0 +1,274 @@ +package external_test + +import ( + "context" + "errors" + + "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/core/external" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/stretchr/testify/mock" +) + +var _ = Describe("Provider - 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() { + 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) + }) + + 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()) + }) + + 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()) + }) + + 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() + + // 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 fallback for song2 + + 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() + + // 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() + + // Since there are no MBIDs, loadTracksByMBID should not make any database call + // loadTracksByTitle should make a database call for title matching + song1 := model.MediaFile{ID: "song-1", Title: "Song One", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song one"} + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + 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() + + // 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() + + // Mock the title fallback query (finds song2 by title) + song2 := model.MediaFile{ID: "song-2", Title: "Song Two", ArtistID: "artist-1", MbzRecordingID: "", OrderTitle: "song two"} + 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()) + }) +}) diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go new file mode 100644 index 0000000..5f5d41a --- /dev/null +++ b/core/external/provider_updatealbuminfo_test.go @@ -0,0 +1,167 @@ +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/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "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) + mockAlbumRepo = ds.Album(ctx).(*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: gg.P(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: gg.P(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()) + }) +}) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go new file mode 100644 index 0000000..9b1e8d8 --- /dev/null +++ b/core/external/provider_updateartistinfo_test.go @@ -0,0 +1,229 @@ +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/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "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) + mockArtistRepo = ds.Artist(ctx).(*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()) + }) + + 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: gg.P(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: gg.P(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: gg.P(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()) + }) +}) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..d134077 --- /dev/null +++ b/core/ffmpeg/ffmpeg.go @@ -0,0 +1,228 @@ +package ffmpeg + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" +) + +type FFmpeg interface { + Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) + ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) + Probe(ctx context.Context, files []string) (string, error) + CmdPath() (string, error) + IsAvailable() bool + Version() string +} + +func New() FFmpeg { + return &ffmpeg{} +} + +const ( + extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" + probeCmd = "ffmpeg %s -f ffmetadata" +) + +type ffmpeg struct{} + +func (e *ffmpeg) Transcode(ctx context.Context, command, path string, maxBitRate, offset int) (io.ReadCloser, error) { + if _, err := ffmpegCmd(); err != nil { + return nil, err + } + // First make sure the file exists + if err := fileExists(path); err != nil { + return nil, err + } + args := createFFmpegCommand(command, path, maxBitRate, offset) + 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 + } + // First make sure the file exists + if err := fileExists(path); err != nil { + return nil, err + } + args := createFFmpegCommand(extractImageCmd, path, 0, 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) Probe(ctx context.Context, files []string) (string, error) { + if _, err := ffmpegCmd(); err != nil { + return "", err + } + args := createProbeCommand(probeCmd, files) + log.Trace(ctx, "Executing ffmpeg command", "args", args) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec + 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 +} + +// 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) (io.ReadCloser, error) { + log.Trace(ctx, "Executing ffmpeg command", "cmd", args) + j := &ffCmd{args: args} + j.PipeReader, j.out = io.Pipe() + err := j.start(ctx) + if err != nil { + return nil, err + } + go j.wait() + return j, nil +} + +type ffCmd struct { + *io.PipeReader + 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 + 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 *ffCmd) wait() { + if err := j.cmd.Wait(); err != nil { + 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)) + } + return + } + _ = j.out.Close() +} + +// 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") { + s = strings.ReplaceAll(s, "%s", path) + args = append(args, s) + if offset > 0 && !strings.Contains(cmd, "%t") { + args = append(args, "-ss", strconv.Itoa(offset)) + } + } else { + s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset)) + s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) + args = append(args, s) + } + } + return args +} + +func createProbeCommand(cmd string, inputs []string) []string { + var args []string + for _, s := range fixCmd(cmd) { + 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.Fields(cmd) + cmdPath, _ := ffmpegCmd() + for i, s := range split { + if s == "ffmpeg" || s == "ffmpeg.exe" { + split[i] = cmdPath + } + } + return split +} + +func ffmpegCmd() (string, error) { + ffOnce.Do(func() { + if conf.Server.FFmpegPath != "" { + ffmpegPath = conf.Server.FFmpegPath + ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath) + } else { + ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg") + if errors.Is(ffmpegErr, exec.ErrDot) { + log.Trace("ffmpeg found in current folder '.'") + ffmpegPath, ffmpegErr = exec.LookPath("./ffmpeg") + } + } + if ffmpegErr == nil { + log.Info("Found ffmpeg", "path", ffmpegPath) + return + } + }) + return ffmpegPath, ffmpegErr +} + +// 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 +) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go new file mode 100644 index 0000000..debe0b5 --- /dev/null +++ b/core/ffmpeg/ffmpeg_test.go @@ -0,0 +1,166 @@ +package ffmpeg + +import ( + "context" + "runtime" + sync "sync" + "testing" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFFmpeg(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "FFmpeg Suite") +} + +var _ = Describe("ffmpeg", func() { + BeforeEach(func() { + _, _ = ffmpegCmd() + ffmpegPath = "ffmpeg" + ffmpegErr = nil + }) + 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) + 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 -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456) + Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"})) + }) + + }) + Context("when command does not have time offset param", func() { + It("adds time offset after 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", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"})) + }) + }) + }) + + 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"})) + }) + }) + + When("ffmpegPath is set", func() { + It("returns the correct ffmpeg path", func() { + ffmpegPath = "/usr/bin/ffmpeg" + args := createProbeCommand(probeCmd, []string{"one.mp3"}) + Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"})) + }) + It("returns the correct ffmpeg path with spaces", func() { + ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe" + args := createProbeCommand(probeCmd, []string{"one.mp3"}) + Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"})) + }) + }) + + 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("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, command, "tests/fixtures/test.mp3", 128, 0) + 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() + + // Next read should fail due to cancelled context + _, err = stream.Read(buf) + Expect(err).To(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, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0) + Expect(err).To(MatchError(context.Canceled)) + }) + }) + + 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, longRunningCmd, "tests/fixtures/test.mp3", 0, 0) + 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()) + }) + }) + }) +}) diff --git a/core/inspect.go b/core/inspect.go new file mode 100644 index 0000000..751cf06 --- /dev/null +++ b/core/inspect.go @@ -0,0 +1,51 @@ +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" + . "github.com/navidrome/navidrome/utils/gg" +) + +type InspectOutput struct { + File string `json:"file"` + RawTags model.RawTags `json:"rawTags"` + MappedTags *model.MediaFile `json:"mappedTags,omitempty"` +} + +func Inspect(filePath string, libraryId int, 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(path, tag) + result := &InspectOutput{ + File: filePath, + RawTags: tags[file].Tags, + MappedTags: P(md.ToMediaFile(libraryId, folderId)), + } + + return result, nil +} diff --git a/core/library.go b/core/library.go new file mode 100644 index 0000000..f4f55ec --- /dev/null +++ b/core/library.go @@ -0,0 +1,407 @@ +package core + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "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/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 + + NewRepository(ctx context.Context) rest.Repository +} + +type libraryService struct { + ds model.DataStore + scanner model.Scanner + watcher Watcher + broker events.Broker +} + +// NewLibrary creates a new Library service +func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library { + return &libraryService{ + ds: ds, + scanner: scanner, + watcher: watcher, + broker: broker, + } +} + +// User-library association operations + +func (s *libraryService) GetUserLibraries(ctx context.Context, userID string) (model.Libraries, error) { + // Verify user exists + if _, err := s.ds.User(ctx).Get(userID); err != nil { + return nil, err + } + + return s.ds.User(ctx).GetUserLibraries(userID) +} + +func (s *libraryService) SetUserLibraries(ctx context.Context, userID string, libraryIDs []int) error { + // Verify user exists + user, err := s.ds.User(ctx).Get(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(ctx).SetUserLibraries(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(ctx).GetUserLibraries(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) NewRepository(ctx context.Context) rest.Repository { + repo := s.ds.Library(ctx) + wrapper := &libraryRepositoryWrapper{ + ctx: ctx, + LibraryRepository: repo, + Repository: repo.(rest.Repository), + ds: s.ds, + scanner: s.scanner, + watcher: s.watcher, + broker: s.broker, + } + return wrapper +} + +type libraryRepositoryWrapper struct { + rest.Repository + model.LibraryRepository + ctx context.Context + ds model.DataStore + scanner model.Scanner + watcher Watcher + broker events.Broker +} + +func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) { + lib := entity.(*model.Library) + if err := r.validateLibrary(lib); err != nil { + return "", err + } + + err := r.LibraryRepository.Put(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(r.ctx, lib); err != nil { + log.Warn(r.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(lib, "new") + } + + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", strconv.Itoa(lib.ID))) + log.Debug(r.ctx, "Library created - sent refresh event", "libraryID", lib.ID, "name", lib.Name) + } + + return strconv.Itoa(lib.ID), nil +} + +func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error { + lib := entity.(*model.Library) + libID, err := strconv.Atoi(id) + if err != nil { + return fmt.Errorf("invalid library ID: %s", id) + } + + lib.ID = libID + if err := r.validateLibrary(lib); err != nil { + return err + } + + // Get the original library to check if path changed + originalLib, err := r.Get(libID) + if err != nil { + return r.mapError(err) + } + + pathChanged := originalLib.Path != lib.Path + + err = r.LibraryRepository.Put(lib) + if err != nil { + return r.mapError(err) + } + + // Restart watcher and trigger scan if path was updated + if pathChanged { + if r.watcher != nil { + if err := r.watcher.Watch(r.ctx, lib); err != nil { + log.Warn(r.ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) + } + } + + if r.scanner != nil { + go r.triggerScan(lib, "updated") + } + } + + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library updated - sent refresh event", "libraryID", libID, "name", lib.Name) + } + + return nil +} + +func (r *libraryRepositoryWrapper) Delete(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(libID) + if err != nil { + return r.mapError(err) + } + + err = r.LibraryRepository.Delete(libID) + 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(r.ctx, libID); err != nil { + log.Warn(r.ctx, "Failed to stop watcher for deleted library", "libraryID", libID, "name", lib.Name, "path", lib.Path, err) + } + } + + if r.scanner != nil { + go r.triggerScan(lib, "deleted") + } + + // Send library refresh event to all clients + if r.broker != nil { + event := &events.RefreshResource{} + r.broker.SendBroadcastMessage(r.ctx, event.With("library", id)) + log.Debug(r.ctx, "Library deleted - sent refresh event", "libraryID", libID, "name", lib.Name) + } + + 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"}} + } + } + + switch { + case errors.Is(err, model.ErrNotFound): + return rest.ErrNotFound + case errors.Is(err, model.ErrNotAuthorized): + return rest.ErrPermissionDenied + default: + return err + } +} + +func (r *libraryRepositoryWrapper) validateLibrary(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(library); err != nil { + validationErrors["path"] = err.Error() + } + } + + if len(validationErrors) > 0 { + return &rest.ValidationError{Errors: validationErrors} + } + + return nil +} + +func (r *libraryRepositoryWrapper) validateLibraryPath(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(r.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(r.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(ctx).CountAll(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 +} + +func (r *libraryRepositoryWrapper) triggerScan(lib *model.Library, action string) { + log.Info(r.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(r.ctx, false) // Quick scan for new library + if err != nil { + log.Error(r.ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) + } else { + log.Info(r.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 new file mode 100644 index 0000000..bf73a62 --- /dev/null +++ b/core/library_test.go @@ -0,0 +1,958 @@ +package core_test + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "sync" + + "github.com/deluan/rest" + _ "github.com/navidrome/navidrome/adapters/taglib" // 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 + + 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{} + service = core.NewLibrary(ds, scanner, watcherManager, broker) + 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 + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + }) + + Describe("Create", func() { + It("creates a new library successfully", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(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(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(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(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(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(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("1", library) + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.Data[1].Name).To(Equal("Updated Library")) + Expect(libraryRepo.Data[1].Path).To(Equal(newTempDir)) + }) + + 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("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("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("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("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("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("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("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("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(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(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(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(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("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("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("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("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("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("1") + + Expect(err).NotTo(HaveOccurred()) + Expect(libraryRepo.Data).To(HaveLen(0)) + }) + + It("fails when library doesn't exist", func() { + err := repo.Delete("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 + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + }) + + It("triggers scan when creating a new library", func() { + library := &model.Library{ID: 1, Name: "New Library", Path: tempDir} + + _, err := repo.Save(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("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("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("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(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("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("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("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(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("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("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("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("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 + + BeforeEach(func() { + r := service.NewRepository(ctx) + repo = r.(rest.Persistable) + // 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(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("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("2") + + Expect(err).NotTo(HaveOccurred()) + Expect(broker.Events).To(HaveLen(1)) + }) + }) +}) + +// 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 new file mode 100644 index 0000000..858a3ff --- /dev/null +++ b/core/lyrics/lyrics.go @@ -0,0 +1,37 @@ +package lyrics + +import ( + "context" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func GetLyrics(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) { + var lyricsList model.LyricList + var err error + + for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.LyricsPriority), ",") { + pattern = strings.TrimSpace(pattern) + switch { + case pattern == "embedded": + lyricsList, err = fromEmbedded(ctx, mf) + case strings.HasPrefix(pattern, "."): + lyricsList, err = fromExternalFile(ctx, mf, pattern) + default: + log.Error(ctx, "Invalid lyric pattern", "pattern", pattern) + } + + if err != nil { + log.Error(ctx, "error parsing lyrics", "source", pattern, err) + } + + if len(lyricsList) > 0 { + return lyricsList, nil + } + } + + return nil, nil +} diff --git a/core/lyrics/lyrics_suite_test.go b/core/lyrics/lyrics_suite_test.go new file mode 100644 index 0000000..f873819 --- /dev/null +++ b/core/lyrics/lyrics_suite_test.go @@ -0,0 +1,17 @@ +package lyrics_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "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") +} diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go new file mode 100644 index 0000000..f4197cc --- /dev/null +++ b/core/lyrics/lyrics_test.go @@ -0,0 +1,124 @@ +package lyrics_test + +import ( + "context" + "encoding/json" + "os" + + "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/utils" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("sources", func() { + var mf model.MediaFile + var ctx context.Context + + const badLyrics = "This is a set of lyrics\nThat is not good" + unsynced, _ := model.ToLyrics("xxx", badLyrics) + embeddedLyrics := model.LyricList{*unsynced} + + syncedLyrics := model.LyricList{ + model.Lyrics{ + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Line: []model.Line{ + { + Start: gg.P(int64(18800)), + Value: "We're no strangers to love", + }, + { + Start: gg.P(int64(22801)), + Value: "You know the rules and so do I", + }, + }, + Offset: gg.P(int64(-100)), + 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, + }, + } + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + + lyricsJson, _ := json.Marshal(embeddedLyrics) + + mf = model.MediaFile{ + Lyrics: string(lyricsJson), + Path: "tests/fixtures/test.mp3", + } + ctx = context.Background() + }) + + DescribeTable("Lyrics Priority", func(priority string, expected model.LyricList) { + conf.Server.LyricsPriority = priority + list, err := lyrics.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("txt > lrc > embedded", ".txt,.lrc,embedded", unsyncedLyrics)) + + 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() { + 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" + + list, err := lyrics.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" + + list, err := lyrics.GetLyrics(ctx, &mf) + Expect(err).To(BeNil()) + Expect(list).To(BeEmpty()) + }) + }) + }) +}) diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go new file mode 100644 index 0000000..857dc2e --- /dev/null +++ b/core/lyrics/sources.go @@ -0,0 +1,51 @@ +package lyrics + +import ( + "context" + "errors" + "os" + "path" + + "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) { + basePath := mf.AbsolutePath() + ext := path.Ext(basePath) + + externalLyric := basePath[0:len(basePath)-len(ext)] + suffix + + contents, err := ioutils.UTF8ReadFile(externalLyric) + if errors.Is(err, os.ErrNotExist) { + log.Trace(ctx, "no lyrics found at path", "path", externalLyric) + return nil, nil + } else if err != nil { + return nil, err + } + + lyrics, err := model.ToLyrics("xxx", string(contents)) + if err != nil { + log.Error(ctx, "error parsing lyric external file", "path", externalLyric, err) + return nil, err + } else if lyrics == nil { + log.Trace(ctx, "empty lyrics from external file", "path", externalLyric) + return nil, nil + } + + log.Trace(ctx, "retrieved lyrics from external file", "path", externalLyric) + + return model.LyricList{*lyrics}, nil +} diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go new file mode 100644 index 0000000..b3d5021 --- /dev/null +++ b/core/lyrics/sources_test.go @@ -0,0 +1,146 @@ +package lyrics + +import ( + "context" + "encoding/json" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("sources", func() { + ctx := context.Background() + + 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" + + synced, _ := model.ToLyrics("eng", syncedLyrics) + unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + + 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() { + It("should return nil for lyrics that don't exist", func() { + mf := model.MediaFile{Path: "tests/fixtures/01 Invisible (RED) Edit Version.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).To(HaveLen(0)) + }) + + It("should return synchronized lyrics from a file", func() { + mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).To(Equal(model.LyricList{ + model.Lyrics{ + DisplayArtist: "Rick Astley", + DisplayTitle: "That one song", + Lang: "eng", + Line: []model.Line{ + { + Start: gg.P(int64(18800)), + Value: "We're no strangers to love", + }, + { + Start: gg.P(int64(22801)), + Value: "You know the rules and so do I", + }, + }, + Offset: gg.P(int64(-100)), + Synced: true, + }, + })) + }) + + It("should return unsynchronized lyrics from a file", func() { + mf := model.MediaFile{Path: "tests/fixtures/test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".txt") + + Expect(err).To(BeNil()) + Expect(lyrics).To(Equal(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, + }, + })) + }) + + It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() { + // The function looks for , so we need to pass + // a MediaFile with .mp3 path and look for .lrc suffix + mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) + Expect(lyrics).To(HaveLen(1)) + + // The critical assertion: even with BOM, synced should be true + 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(gg.P(int64(0)))) + Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) + }) + + It("should handle UTF-16 LE encoded LRC files", func() { + mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"} + lyrics, err := fromExternalFile(ctx, &mf, ".lrc") + + Expect(err).To(BeNil()) + Expect(lyrics).ToNot(BeNil()) + Expect(lyrics).To(HaveLen(1)) + + // UTF-16 should be properly converted to UTF-8 + 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(gg.P(int64(18800)))) + Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) + Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) + Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) + }) + }) +}) diff --git a/core/maintenance.go b/core/maintenance.go new file mode 100644 index 0000000..750fd3a --- /dev/null +++ b/core/maintenance.go @@ -0,0 +1,226 @@ +package core + +import ( + "context" + "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" +) + +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 +} + +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) +} + +// 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(ctx).DeleteAllMissing() + return err + } + return tx.MediaFile(ctx).DeleteMissing(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 + 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(ctx) + mfRepo := s.ds.MediaFile(ctx) + + // Batch load existing albums + albums, err := albumRepo.GetAll(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(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(&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(ctx).GetAll(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.Add(1) + go func() { + defer s.wg.Done() + bgCtx := request.AddValues(context.Background(), ctx) + if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil { + log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err) + } 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 new file mode 100644 index 0000000..09b4424 --- /dev/null +++ b/core/maintenance_test.go @@ -0,0 +1,364 @@ +package core + +import ( + "context" + "errors" + "sync" + + "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")) + }) + }) + }) +}) + +// 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(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(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(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(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(allArtists) +} + +func (m *extendedArtistRepo) IsRefreshStatsCalled() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.refreshStatsCalled +} diff --git a/core/media_streamer.go b/core/media_streamer.go new file mode 100644 index 0000000..c741ed4 --- /dev/null +++ b/core/media_streamer.go @@ -0,0 +1,227 @@ +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, offset int) (*Stream, error) + DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset 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 + filePath string + format string + bitRate int + offset int +} + +func (j *streamJob) Key() string { + return fmt.Sprintf("%s.%s.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.format, j.offset) +} + +func (ms *mediaStreamer) NewStream(ctx context.Context, id string, reqFormat string, reqBitRate int, reqOffset int) (*Stream, error) { + mf, err := ms.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, err + } + + return ms.DoStream(ctx, mf, reqFormat, reqBitRate, reqOffset) +} + +func (ms *mediaStreamer) DoStream(ctx context.Context, mf *model.MediaFile, reqFormat string, reqBitRate int, reqOffset 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} + filePath := mf.AbsolutePath() + + if format == "raw" { + log.Debug(ctx, "Streaming RAW file", "id", mf.ID, "path", filePath, + "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "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, + offset: reqOffset, + } + 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", filePath, + "requestBitrate", reqBitRate, "requestFormat", reqFormat, "requestOffset", reqOffset, + "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 + } + + // Choose the appropriate context based on EnableTranscodingCancellation configuration. + // This is where we decide whether transcoding processes should be cancellable or not. + var transcodingCtx context.Context + if conf.Server.EnableTranscodingCancellation { + // Use the request context directly, allowing cancellation when client disconnects + transcodingCtx = ctx + } else { + // Use background context with request values preserved. + // This prevents cancellation but maintains request metadata (user, client, etc.) + transcodingCtx = request.AddValues(context.Background(), ctx) + } + + out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset) + 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 0000000..44fbf70 --- /dev/null +++ b/core/media_streamer_Internal_test.go @@ -0,0 +1,162 @@ +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: 192} + 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(192)) + }) + 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, "", 160) + Expect(format).To(Equal("oga")) + Expect(bitRate).To(Equal(160)) + }) + }) + }) +}) diff --git a/core/media_streamer_test.go b/core/media_streamer_test.go new file mode 100644 index 0000000..f517549 --- /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, 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, 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, 0) + 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, 0) + 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, 0) + 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, 0) + Expect(err).To(BeNil()) + Expect(s.Seekable()).To(BeTrue()) + }) + }) +}) diff --git a/core/metrics/insights.go b/core/metrics/insights.go new file mode 100644 index 0000000..411bc9a --- /dev/null +++ b/core/metrics/insights.go @@ -0,0 +1,330 @@ +package metrics + +import ( + "bytes" + "context" + "encoding/json" + "math" + "net/http" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "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/schema" + "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 + pluginLoader PluginLoader + lastRun atomic.Int64 + lastStatus atomic.Bool +} + +// PluginLoader defines an interface for loading plugins +type PluginLoader interface { + PluginList() map[string]schema.PluginManifest +} + +func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights { + return singleton.GetInstance(func() *insightsCollector { + id, err := ds.Property(context.TODO()).Get(consts.InsightsIDKey) + if err != nil { + log.Trace("Could not get Insights ID from DB. Creating one", err) + id = uuid.NewString() + err = ds.Property(context.TODO()).Put(consts.InsightsIDKey, id) + if err != nil { + log.Trace("Could not save Insights ID to DB", err) + } + } + insightsID = id + return &insightsCollector{ds: ds, pluginLoader: pluginLoader} + }) +} + +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(ctx).CountAll(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 := &http.Client{ + Timeout: 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) + 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 +} + +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 + packageFilename := filepath.Join(conf.Server.DataFolder, ".package") + packageFileData, err := os.ReadFile(packageFilename) + if err == nil { + data.OS.Package = string(packageFileData) + } + + // 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) + if conf.Server.CacheFolder != "" { + data.FS.Cache = getFSInfo(conf.Server.CacheFolder) + } + if conf.Server.Backup.Path != "" { + data.FS.Backup = getFSInfo(conf.Server.Backup.Path) + } + + // Config info + data.Config.LogLevel = conf.Server.LogLevel + data.Config.LogFileConfigured = conf.Server.LogFile != "" + data.Config.TLSConfigured = conf.Server.TLSCert != "" && conf.Server.TLSKey != "" + data.Config.DefaultBackgroundURLSet = conf.Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL + data.Config.EnableArtworkPrecache = conf.Server.EnableArtworkPrecache + 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.EnableSpotify = conf.Server.Spotify.ID != "" && conf.Server.Spotify.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.SearchFullString + 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.ScanSchedule = conf.Server.Scanner.Schedule + data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds())) + data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup + data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != "" + data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != "" + data.Config.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(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading tracks count", err) + } + data.Library.Albums, err = c.ds.Album(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading albums count", err) + } + data.Library.Artists, err = c.ds.Artist(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading artists count", err) + } + data.Library.Playlists, err = c.ds.Playlist(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading playlists count", err) + } + data.Library.Shares, err = c.ds.Share(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading shares count", err) + } + data.Library.Radios, err = c.ds.Radio(ctx).Count() + if err != nil { + log.Trace(ctx, "Error reading radios count", err) + } + data.Library.Libraries, err = c.ds.Library(ctx).CountAll() + if err != nil { + log.Trace(ctx, "Error reading libraries count", err) + } + data.Library.ActiveUsers, err = c.ds.User(ctx).CountAll(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) + } + + // 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(ctx).CountByClient(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(ctx).CountAll(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 { + plugins := make(map[string]insights.PluginInfo) + for id, manifest := range c.pluginLoader.PluginList() { + plugins[id] = insights.PluginInfo{ + Name: manifest.Name, + Version: manifest.Version, + } + } + return plugins +} diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go new file mode 100644 index 0000000..c46eb87 --- /dev/null +++ b/core/metrics/insights/data.go @@ -0,0 +1,90 @@ +package insights + +type Data struct { + InsightsID string `json:"id"` + Version string `json:"version"` + Uptime int64 `json:"uptime"` + 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"` + } `json:"library"` + Config struct { + LogLevel string `json:"logLevel,omitempty"` + LogFileConfigured bool `json:"logFileConfigured,omitempty"` + TLSConfigured bool `json:"tlsConfigured,omitempty"` + ScannerEnabled bool `json:"scannerEnabled,omitempty"` + ScanSchedule string `json:"scanSchedule,omitempty"` + ScanWatcherWait uint64 `json:"scanWatcherWait,omitempty"` + ScanOnStartup bool `json:"scanOnStartup,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"` + EnableSpotify bool `json:"enableSpotify,omitempty"` + EnableJukebox bool `json:"enableJukebox,omitempty"` + EnablePrometheus bool `json:"enablePrometheus,omitempty"` + EnableCoverAnimation bool `json:"enableCoverAnimation,omitempty"` + EnableNowPlaying bool `json:"enableNowPlaying,omitempty"` + SessionTimeout uint64 `json:"sessionTimeout,omitempty"` + SearchFullString bool `json:"searchFullString,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 new file mode 100644 index 0000000..ad59182 --- /dev/null +++ b/core/metrics/insights_darwin.go @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..98c3456 --- /dev/null +++ b/core/metrics/insights_default.go @@ -0,0 +1,9 @@ +//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_linux.go b/core/metrics/insights_linux.go new file mode 100644 index 0000000..f37c945 --- /dev/null +++ b/core/metrics/insights_linux.go @@ -0,0 +1,102 @@ +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 new file mode 100644 index 0000000..aad1670 --- /dev/null +++ b/core/metrics/insights_windows.go @@ -0,0 +1,53 @@ +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/prometheus.go b/core/metrics/prometheus.go new file mode 100644 index 0000000..4124831 --- /dev/null +++ b/core/metrics/prometheus.go @@ -0,0 +1,240 @@ +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(ctx).CountAll() + if err != nil { + log.Warn("album CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "album"}).Set(float64(albumsCount)) + + artistCount, err := ds.Artist(ctx).CountAll() + if err != nil { + log.Warn("artist CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "artist"}).Set(float64(artistCount)) + + songsCount, err := ds.MediaFile(ctx).CountAll() + if err != nil { + log.Warn("media CountAll error", err) + return + } + targetGauge.With(prometheus.Labels{"model": "media"}).Set(float64(songsCount)) + + usersCount, err := ds.User(ctx).CountAll() + 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/mock_library_service.go b/core/mock_library_service.go new file mode 100644 index 0000000..56f2abd --- /dev/null +++ b/core/mock_library_service.go @@ -0,0 +1,46 @@ +package core + +import ( + "context" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" +) + +// MockLibraryWrapper provides a simple wrapper around MockLibraryRepo +// that implements the core.Library interface for testing +type MockLibraryWrapper struct { + *tests.MockLibraryRepo +} + +// MockLibraryRestAdapter adapts MockLibraryRepo to rest.Repository interface +type MockLibraryRestAdapter struct { + *tests.MockLibraryRepo +} + +// NewMockLibraryService creates a new mock library service for testing +func NewMockLibraryService() Library { + repo := &tests.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 &MockLibraryWrapper{MockLibraryRepo: repo} +} + +func (m *MockLibraryWrapper) NewRepository(ctx context.Context) rest.Repository { + return &MockLibraryRestAdapter{MockLibraryRepo: m.MockLibraryRepo} +} + +// rest.Repository interface implementation + +func (a *MockLibraryRestAdapter) Delete(id string) error { + return a.DeleteByStringID(id) +} + +var _ Library = (*MockLibraryWrapper)(nil) +var _ rest.Repository = (*MockLibraryRestAdapter)(nil) diff --git a/core/playback/device.go b/core/playback/device.go new file mode 100644 index 0000000..fd08b34 --- /dev/null +++ b/core/playback/device.go @@ -0,0 +1,299 @@ +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 + 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 new file mode 100644 index 0000000..f356a14 --- /dev/null +++ b/core/playback/mpv/mpv.go @@ -0,0 +1,131 @@ +package mpv + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + "sync" + + "github.com/kballard/go-shellquote" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" +) + +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 { + 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)) + } + 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_suite_test.go b/core/playback/mpv/mpv_suite_test.go new file mode 100644 index 0000000..f8f8276 --- /dev/null +++ b/core/playback/mpv/mpv_suite_test.go @@ -0,0 +1,17 @@ +package mpv + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestMPV(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "MPV Suite") +} diff --git a/core/playback/mpv/mpv_test.go b/core/playback/mpv/mpv_test.go new file mode 100644 index 0000000..20c0250 --- /dev/null +++ b/core/playback/mpv/mpv_test.go @@ -0,0 +1,390 @@ +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/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(Equal([]string{})) + }) + }) + }) + + 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() { + 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() { + 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() { + 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 new file mode 100644 index 0000000..5c91d94 --- /dev/null +++ b/core/playback/mpv/sockets.go @@ -0,0 +1,22 @@ +//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 new file mode 100644 index 0000000..a85e1e7 --- /dev/null +++ b/core/playback/mpv/sockets_win.go @@ -0,0 +1,19 @@ +//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 new file mode 100644 index 0000000..14170ef --- /dev/null +++ b/core/playback/mpv/track.go @@ -0,0 +1,223 @@ +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 int = 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 new file mode 100644 index 0000000..8e1134f --- /dev/null +++ b/core/playback/playback_suite_test.go @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..7dd02dc --- /dev/null +++ b/core/playback/playbackserver.go @@ -0,0 +1,127 @@ +// 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(*ps.ctx).Get(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 new file mode 100644 index 0000000..0c230a6 --- /dev/null +++ b/core/playback/queue.go @@ -0,0 +1,136 @@ +package playback + +import ( + "fmt" + "math/rand" + + "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 { + filenames := "" + for idx, item := range pd.Items { + filenames += fmt.Sprint(idx) + ":" + item.Path + " " + } + return fmt.Sprintf("#Items: %d, idx: %d, files: %s", len(pd.Items), pd.Index, filenames) +} + +// 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 + } + + 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 new file mode 100644 index 0000000..00df522 --- /dev/null +++ b/core/playback/queue_test.go @@ -0,0 +1,121 @@ +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 new file mode 100644 index 0000000..9639145 --- /dev/null +++ b/core/players.go @@ -0,0 +1,82 @@ +package core + +import ( + "context" + "fmt" + "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/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) +} + +func NewPlayers(ds model.DataStore) Players { + return &players{ + ds: ds, + limiter: utils.Limiter{Interval: consts.UpdatePlayerFrequency}, + } +} + +type players struct { + ds model.DataStore + limiter utils.Limiter +} + +func (p *players) Register(ctx context.Context, playerID, 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(ctx).Get(playerID) + if err == nil && plr.Client != client { + playerID = "" + } + } + username := userName(ctx) + if err != nil || playerID == "" { + plr, err = p.ds.Player(ctx).FindMatch(user.ID, client, userAgent) + if err == nil { + 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, + Client: client, + ScrobbleEnabled: true, + ReportRealPath: conf.Server.Subsonic.DefaultReportRealPath, + } + log.Info(ctx, "Registering new player", "id", plr.ID, "client", client, "username", username, "type", userAgent) + } + } + plr.Name = fmt.Sprintf("%s [%s]", client, userAgent) + plr.UserAgent = userAgent + plr.IP = ip + plr.LastSeen = time.Now() + p.limiter.Do(plr.ID, func() { + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + err = p.ds.Player(ctx).Put(plr) + if err != nil { + log.Warn(ctx, "Could not save player", "id", plr.ID, "client", client, "username", username, "type", userAgent, err) + } + }) + if plr.TranscodingId != "" { + trc, err = p.ds.Transcoding(ctx).Get(plr.TranscodingId) + } + return plr, trc, err +} + +func (p *players) Get(ctx context.Context, playerId string) (*model.Player, error) { + return p.ds.Player(ctx).Get(playerId) +} diff --git a/core/players_test.go b/core/players_test.go new file mode 100644 index 0000000..90c265f --- /dev/null +++ b/core/players_test.go @@ -0,0 +1,156 @@ +package core + +import ( + "context" + "time" + + "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("Players", func() { + var players Players + var repo *mockPlayerRepository + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "johndoe"}) + ctx = request.WithUsername(ctx, "johndoe") + var beforeRegister time.Time + + BeforeEach(func() { + repo = &mockPlayerRepository{} + ds := &tests.MockDataStore{MockedPlayer: repo, MockedTranscoding: &tests.MockTranscodingRepo{}} + players = NewPlayers(ds) + beforeRegister = time.Now() + }) + + Describe("Register", func() { + It("creates a new player when no ID is specified", func() { + p, trc, err := players.Register(ctx, "", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + 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.UserAgent).To(Equal("chrome")) + Expect(repo.lastSaved).To(Equal(p)) + Expect(trc).To(BeNil()) + }) + + It("creates a new player if it cannot find any matching player", func() { + p, trc, err := players.Register(ctx, "123", "client", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).ToNot(BeEmpty()) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(repo.lastSaved).To(Equal(p)) + Expect(trc).To(BeNil()) + }) + + It("creates a new player if client does not match the one in DB", func() { + plr := &model.Player{ID: "123", Name: "A Player", Client: "client1111", LastSeen: time.Time{}} + repo.add(plr) + p, trc, err := players.Register(ctx, "123", "client2222", "chrome", "1.2.3.4") + Expect(err).ToNot(HaveOccurred()) + Expect(p.ID).ToNot(BeEmpty()) + Expect(p.ID).ToNot(Equal("123")) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(p.Client).To(Equal("client2222")) + Expect(trc).To(BeNil()) + }) + + It("finds players by ID", func() { + 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()) + Expect(p.ID).To(Equal("123")) + Expect(p.LastSeen).To(BeTemporally(">=", beforeRegister)) + Expect(repo.lastSaved).To(Equal(p)) + Expect(trc).To(BeNil()) + }) + + 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{}} + repo.add(plr) + p, _, err := players.Register(ctx, "999", "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)) + }) + + 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)) + }) + + It("finds player by ID and return its transcoding", func() { + plr := &model.Player{ID: "123", Name: "A Player", Client: "client", LastSeen: time.Time{}, TranscodingId: "1"} + repo.add(plr) + p, trc, err := players.Register(ctx, "123", "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)) + Expect(trc.ID).To(Equal("1")) + }) + + 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)) + }) + }) + }) +}) + +type mockPlayerRepository struct { + model.PlayerRepository + lastSaved *model.Player + data map[string]model.Player +} + +func (m *mockPlayerRepository) add(p *model.Player) { + if m.data == nil { + m.data = make(map[string]model.Player) + } + m.data[p.ID] = *p +} + +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(userId, client, userAgent string) (*model.Player, error) { + for _, p := range m.data { + if p.Client == client && p.UserId == userId && p.UserAgent == userAgent { + return &p, nil + } + } + return nil, model.ErrNotFound +} + +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 0000000..ed90cc2 --- /dev/null +++ b/core/playlists.go @@ -0,0 +1,487 @@ +package core + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/RaveNoX/go-jsoncommentstrip" + "github.com/bmatcuk/doublestar/v4" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" + "github.com/navidrome/navidrome/utils/slice" + "golang.org/x/text/unicode/norm" +) + +type Playlists interface { + ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) + Update(ctx context.Context, playlistID string, name *string, comment *string, public *bool, idsToAdd []string, idxToRemove []int) error + ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) +} + +type playlists struct { + ds model.DataStore +} + +func NewPlaylists(ds model.DataStore) Playlists { + return &playlists{ds: ds} +} + +func InPlaylistsPath(folder model.Folder) bool { + if conf.Server.PlaylistsPath == "" { + return true + } + rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath()) + for _, path := range strings.Split(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) { + if match, _ := doublestar.Match(path, rel); match { + return true + } + } + return false +} + +func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { + pls, err := s.parsePlaylist(ctx, filename, folder) + if err != nil { + log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) + return nil, err + } + log.Debug("Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) + err = s.updatePlaylist(ctx, pls) + if err != nil { + log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) + } + return pls, err +} + +func (s *playlists) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) { + owner, _ := request.UserFrom(ctx) + pls := &model.Playlist{ + OwnerID: owner.ID, + Public: false, + Sync: false, + } + err := s.parseM3U(ctx, pls, nil, reader) + if err != nil { + log.Error(ctx, "Error parsing playlist", err) + return nil, err + } + err = s.ds.Playlist(ctx).Put(pls) + if err != nil { + log.Error(ctx, "Error saving playlist", err) + return nil, err + } + return pls, nil +} + +func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, folder *model.Folder) (*model.Playlist, error) { + pls, err := s.newSyncedPlaylist(folder.AbsolutePath(), playlistFile) + if err != nil { + return nil, err + } + + file, err := os.Open(pls.Path) + if err != nil { + return nil, err + } + defer file.Close() + + reader := ioutils.UTF8Reader(file) + extension := strings.ToLower(filepath.Ext(playlistFile)) + switch extension { + case ".nsp": + err = s.parseNSP(ctx, pls, reader) + default: + err = s.parseM3U(ctx, pls, folder, reader) + } + return pls, err +} + +func (s *playlists) newSyncedPlaylist(baseDir string, playlistFile string) (*model.Playlist, error) { + playlistPath := filepath.Join(baseDir, playlistFile) + info, err := os.Stat(playlistPath) + if err != nil { + return nil, err + } + + var extension = filepath.Ext(playlistFile) + var name = playlistFile[0 : len(playlistFile)-len(extension)] + + pls := &model.Playlist{ + Name: name, + Comment: fmt.Sprintf("Auto-imported from '%s'", playlistFile), + Public: false, + Path: playlistPath, + Sync: true, + UpdatedAt: info.ModTime(), + } + return pls, nil +} + +func getPositionFromOffset(data []byte, offset int64) (line, column int) { + line = 1 + for _, b := range data[:offset] { + if b == '\n' { + line++ + column = 1 + } else { + column++ + } + } + return +} + +func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.Reader) error { + nsp := &nspFile{} + reader = io.LimitReader(reader, 100*1024) // Limit to 100KB + reader = jsoncommentstrip.NewReader(reader) + input, err := io.ReadAll(reader) + if err != nil { + return fmt.Errorf("reading SmartPlaylist: %w", err) + } + err = json.Unmarshal(input, nsp) + if err != nil { + var syntaxErr *json.SyntaxError + if errors.As(err, &syntaxErr) { + line, col := getPositionFromOffset(input, syntaxErr.Offset) + return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) + } + return fmt.Errorf("JSON parsing error in SmartPlaylist: %w", err) + } + pls.Rules = &nsp.Criteria + if nsp.Name != "" { + pls.Name = nsp.Name + } + if nsp.Comment != "" { + pls.Comment = nsp.Comment + } + return nil +} + +func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *model.Folder, reader io.Reader) error { + mediaFileRepository := s.ds.MediaFile(ctx) + var mfs model.MediaFiles + for lines := range slice.CollectChunks(slice.LinesFrom(reader), 400) { + filteredLines := make([]string, 0, len(lines)) + for _, line := range lines { + line := strings.TrimSpace(line) + if strings.HasPrefix(line, "#PLAYLIST:") { + pls.Name = line[len("#PLAYLIST:"):] + continue + } + // Skip empty lines and extended info + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "file://") { + line = strings.TrimPrefix(line, "file://") + line, _ = url.QueryUnescape(line) + } + if !model.IsAudioFile(line) { + continue + } + filteredLines = append(filteredLines, line) + } + resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines) + if err != nil { + log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err) + continue + } + + // Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD. + // See https://github.com/navidrome/navidrome/issues/4663 + resolvedPaths = slice.Map(resolvedPaths, func(path string) string { + return strings.ToLower(norm.NFD.String(path)) + }) + + found, err := mediaFileRepository.FindByPaths(resolvedPaths) + 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 + existing := make(map[string]int, len(found)) + for idx := range found { + // Normalize to lowercase for case-insensitive comparison + // Key format: "libraryID:path" + key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path)) + existing[key] = idx + } + + // Find media files in the order of the resolved paths, to keep playlist order + for _, path := range resolvedPaths { + idx, ok := existing[path] + if ok { + mfs = append(mfs, found[idx]) + } else { + log.Warn(ctx, "Path in playlist not found", "playlist", pls.Name, "path", 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 +} + +// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching. +type libraryMatcher struct { + libraries model.Libraries + cleanedPaths []string +} + +// findLibraryForPath finds which library contains the given absolute path. +// Returns library ID and path, or 0 and empty string if not found. +func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { + // Check sorted libraries (longest path first) to find the best match + for i, cleanLibPath := range lm.cleanedPaths { + // Check if absolutePath is under this library path + if strings.HasPrefix(absolutePath, cleanLibPath) { + // Ensure it's a proper path boundary (not just a prefix) + if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { + return lm.libraries[i].ID, cleanLibPath + } + } + } + return 0, "" +} + +// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). +// This ensures correct matching when library paths are prefixes of each other. +// Example: /music-classical must be checked before /music +// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical +func newLibraryMatcher(libs model.Libraries) *libraryMatcher { + // Sort libraries by path length (descending) to ensure longest paths match first. + slices.SortFunc(libs, func(i, j model.Library) int { + return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending + }) + + // Pre-clean all library paths once for efficient matching + cleanedPaths := make([]string, len(libs)) + for i, lib := range libs { + cleanedPaths[i] = filepath.Clean(lib.Path) + } + return &libraryMatcher{ + libraries: libs, + cleanedPaths: cleanedPaths, + } +} + +// pathResolver handles path resolution logic for playlist imports. +type pathResolver struct { + matcher *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(ctx).GetAll() + if err != nil { + return nil, err + } + matcher := 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 { + libID, libPath := r.matcher.findLibraryForPath(absolutePath) + if libID == 0 { + return pathResolution{valid: false} + } + return pathResolution{ + absolutePath: absolutePath, + libraryPath: libPath, + libraryID: libID, + 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 (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) { + resolver, err := newPathResolver(ctx, s.ds) + if err != nil { + return nil, err + } + + results := make([]string, 0, len(lines)) + for idx, line := range lines { + resolution := resolver.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 +} + +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 + newPls.Public = conf.Server.DefaultPlaylistPublicVisibility + } + return s.ds.Playlist(ctx).Put(newPls) +} + +func (s *playlists) Update(ctx context.Context, playlistID string, + name *string, comment *string, public *bool, + idsToAdd []string, idxToRemove []int) error { + needsInfoUpdate := name != nil || comment != nil || public != nil + needsTrackRefresh := len(idxToRemove) > 0 + + return s.ds.WithTxImmediate(func(tx model.DataStore) error { + var pls *model.Playlist + var err error + repo := tx.Playlist(ctx) + tracks := repo.Tracks(playlistID, true) + if tracks == nil { + return fmt.Errorf("%w: playlist '%s'", model.ErrNotFound, playlistID) + } + if needsTrackRefresh { + pls, err = repo.GetWithTracks(playlistID, true, false) + pls.RemoveTracks(idxToRemove) + pls.AddMediaFilesByID(idsToAdd) + } else { + if len(idsToAdd) > 0 { + _, err = tracks.Add(idsToAdd) + if err != nil { + return err + } + } + if needsInfoUpdate { + pls, err = repo.Get(playlistID) + } + } + if err != nil { + return err + } + if !needsTrackRefresh && !needsInfoUpdate { + return nil + } + + if name != nil { + pls.Name = *name + } + if comment != nil { + pls.Comment = *comment + } + if public != nil { + pls.Public = *public + } + // Special case: The playlist is now empty + if len(idxToRemove) > 0 && len(pls.Tracks) == 0 { + if err = tracks.DeleteAll(); err != nil { + return err + } + } + return repo.Put(pls) + }) +} + +type nspFile struct { + criteria.Criteria + Name string `json:"name"` + Comment string `json:"comment"` +} + +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_internal_test.go b/core/playlists_internal_test.go new file mode 100644 index 0000000..88e36cc --- /dev/null +++ b/core/playlists_internal_test.go @@ -0,0 +1,406 @@ +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("libraryMatcher", func() { + var ds *tests.MockDataStore + var mockLibRepo *tests.MockLibraryRepo + ctx := context.Background() + + BeforeEach(func() { + mockLibRepo = &tests.MockLibraryRepo{} + ds = &tests.MockDataStore{ + MockedLibrary: mockLibRepo, + } + }) + + // Helper function to create a libraryMatcher from the mock datastore + createMatcher := func(ds model.DataStore) *libraryMatcher { + libs, err := ds.Library(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + return newLibraryMatcher(libs) + } + + Describe("Longest library path matching", func() { + It("matches the longest library path when multiple libraries share a prefix", func() { + // Setup libraries with prefix conflicts + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music"}, + {ID: 2, Path: "/music-classical"}, + {ID: 3, Path: "/music-classical/opera"}, + }) + + matcher := createMatcher(ds) + + // Test that longest path matches first and returns correct library ID + testCases := []struct { + path string + expectedLibID int + expectedLibPath string + }{ + {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"}, + {"/music-classical/track.mp3", 2, "/music-classical"}, + {"/music/track.mp3", 1, "/music"}, + {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"}, + } + + for _, tc := range testCases { + libID, libPath := matcher.findLibraryForPath(tc.path) + Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID) + Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath) + } + }) + + It("handles libraries with similar prefixes but different structures", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/home/user/music"}, + {ID: 2, Path: "/home/user/music-backup"}, + }) + + matcher := createMatcher(ds) + + // Test that music-backup library is matched correctly + libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3") + Expect(libID).To(Equal(2)) + Expect(libPath).To(Equal("/home/user/music-backup")) + + // Test that music library is still matched correctly + libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3") + Expect(libID).To(Equal(1)) + Expect(libPath).To(Equal("/home/user/music")) + }) + + It("matches path that is exactly the library root", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music"}, + {ID: 2, Path: "/music-classical"}, + }) + + matcher := createMatcher(ds) + + // Exact library path should match + libID, libPath := matcher.findLibraryForPath("/music-classical") + Expect(libID).To(Equal(2)) + Expect(libPath).To(Equal("/music-classical")) + }) + + It("handles complex nested library structures", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/media"}, + {ID: 2, Path: "/media/audio"}, + {ID: 3, Path: "/media/audio/classical"}, + {ID: 4, Path: "/media/audio/classical/baroque"}, + }) + + matcher := createMatcher(ds) + + testCases := []struct { + path string + expectedLibID int + expectedLibPath string + }{ + {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"}, + {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"}, + {"/media/audio/rock/track.mp3", 2, "/media/audio"}, + {"/media/video/movie.mp4", 1, "/media"}, + } + + for _, tc := range testCases { + libID, libPath := matcher.findLibraryForPath(tc.path) + Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) + Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath) + } + }) + }) + + Describe("Edge cases", func() { + It("handles empty library list", func() { + mockLibRepo.SetData([]model.Library{}) + + matcher := createMatcher(ds) + Expect(matcher).ToNot(BeNil()) + + // Should not match anything + libID, libPath := matcher.findLibraryForPath("/music/track.mp3") + Expect(libID).To(Equal(0)) + Expect(libPath).To(BeEmpty()) + }) + + It("handles single library", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music"}, + }) + + matcher := createMatcher(ds) + + libID, libPath := matcher.findLibraryForPath("/music/track.mp3") + Expect(libID).To(Equal(1)) + Expect(libPath).To(Equal("/music")) + }) + + It("handles libraries with special characters in paths", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/music[test]"}, + {ID: 2, Path: "/music(backup)"}, + }) + + matcher := createMatcher(ds) + Expect(matcher).ToNot(BeNil()) + + // Special characters should match literally + libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3") + Expect(libID).To(Equal(1)) + Expect(libPath).To(Equal("/music[test]")) + }) + }) + + Describe("Path matching order", func() { + It("ensures longest paths match first", func() { + mockLibRepo.SetData([]model.Library{ + {ID: 1, Path: "/a"}, + {ID: 2, Path: "/ab"}, + {ID: 3, Path: "/abc"}, + }) + + matcher := createMatcher(ds) + + // Verify that longer paths match correctly (not cut off by shorter prefix) + testCases := []struct { + path string + expectedLibID int + }{ + {"/abc/file.mp3", 3}, + {"/ab/file.mp3", 2}, + {"/a/file.mp3", 1}, + } + + for _, tc := range testCases { + libID, _ := matcher.findLibraryForPath(tc.path) + Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID) + } + }) + }) +}) + +var _ = Describe("pathResolver", func() { + var ds *tests.MockDataStore + var mockLibRepo *tests.MockLibraryRepo + var resolver *pathResolver + ctx := context.Background() + + BeforeEach(func() { + 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() { + 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()) + }) + }) + + Describe("resolvePath", func() { + Context("With absolute paths", 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_test.go b/core/playlists_test.go new file mode 100644 index 0000000..6aa8aac --- /dev/null +++ b/core/playlists_test.go @@ -0,0 +1,589 @@ +package core_test + +import ( + "context" + "os" + "strconv" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/text/unicode/norm" +) + +var _ = Describe("Playlists", func() { + var ds *tests.MockDataStore + var ps core.Playlists + var mockPlsRepo mockedPlaylistRepo + var mockLibRepo *tests.MockLibraryRepo + ctx := context.Background() + + BeforeEach(func() { + mockPlsRepo = mockedPlaylistRepo{} + mockLibRepo = &tests.MockLibraryRepo{} + ds = &tests.MockDataStore{ + MockedPlaylist: &mockPlsRepo, + MockedLibrary: mockLibRepo, + } + ctx = request.WithUser(ctx, model.User{ID: "123"}) + }) + + Describe("ImportFile", func() { + var folder *model.Folder + BeforeEach(func() { + ps = core.NewPlaylists(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.ImportFile(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.ImportFile(ctx, folder, "lf-ended.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(2)) + }) + + It("parses playlists using CR ending (old Mac format)", func() { + pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u") + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(2)) + }) + + It("parses playlists with UTF-8 BOM marker", func() { + pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u") + 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.ImportFile(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")) + }) + }) + + Describe("NSP", func() { + It("parses well-formed playlists", func() { + pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.last).To(Equal(pls)) + Expect(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() { + _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp") + Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'")) + }) + }) + + 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 = core.NewPlaylists(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.ImportFile(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.ImportFile(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.ImportFile(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.ImportFile(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 = core.NewPlaylists(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.ImportFile(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("ImportM3U", func() { + var repo *mockedMediaFileFromListRepo + BeforeEach(func() { + repo = &mockedMediaFileFromListRepo{} + ds.MockedMediaFile = repo + ps = core.NewPlaylists(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 other 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("handles Unicode normalization when comparing paths (NFD vs NFC)", func() { + // Simulate macOS filesystem: stores paths in NFD (decomposed) form + // "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD + nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave + repo.data = []string{nfdPath} + + // Simulate Apple Music M3U: uses NFC (composed) form + nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character + m3u := nfcPath + "\n" + f := strings.NewReader(m3u) + pls, err := ps.ImportM3U(ctx, f) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Tracks).To(HaveLen(1)) + // Should match despite different Unicode normalization forms + Expect(pls.Tracks[0].Path).To(Equal(nfdPath)) + }) + + }) + + Describe("InPlaylistsPath", 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(core.InPlaylistsPath(folder)).To(BeTrue()) + }) + + It("returns true if PlaylistsPath is any (**/**)", func() { + conf.Server.PlaylistsPath = "**/**" + Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + }) + + It("returns true if folder is in PlaylistsPath", func() { + conf.Server.PlaylistsPath = "other/**:playlists/**" + Expect(core.InPlaylistsPath(folder)).To(BeTrue()) + }) + + It("returns false if folder is not in PlaylistsPath", func() { + conf.Server.PlaylistsPath = "other" + Expect(core.InPlaylistsPath(folder)).To(BeFalse()) + }) + + It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() { + conf.Server.PlaylistsPath = "." + Expect(core.InPlaylistsPath(folder)).To(BeFalse()) + + folder2 := model.Folder{ + LibraryPath: "/music", + Path: "", + Name: ".", + } + + Expect(core.InPlaylistsPath(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(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(paths []string) (model.MediaFiles, error) { + var mfs model.MediaFiles + + for idx, dataPath := range r.data { + // Normalize the data path to NFD (simulates macOS filesystem storage) + normalizedDataPath := norm.NFD.String(dataPath) + + for _, requestPath := range paths { + // Strip library qualifier if present (format: "libraryID:path") + actualPath := requestPath + 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] + } + } + + // The request path should already be normalized to NFD by production code + // before calling FindByPaths (to match DB storage) + normalizedRequestPath := norm.NFD.String(actualPath) + + // Case-insensitive comparison (like SQL's "collate nocase") + if strings.EqualFold(normalizedRequestPath, normalizedDataPath) { + mfs = append(mfs, model.MediaFile{ + ID: strconv.Itoa(idx), + Path: dataPath, // Return original path from DB + LibraryID: libraryID, + }) + break + } + } + } + return mfs, nil +} + +type mockedPlaylistRepo struct { + last *model.Playlist + model.PlaylistRepository +} + +func (r *mockedPlaylistRepo) FindByPath(string) (*model.Playlist, error) { + return nil, model.ErrNotFound +} + +func (r *mockedPlaylistRepo) Put(pls *model.Playlist) error { + r.last = pls + return nil +} diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go new file mode 100644 index 0000000..4f64a3c --- /dev/null +++ b/core/scrobbler/buffered_scrobbler.go @@ -0,0 +1,133 @@ +package scrobbler + +import ( + "context" + "errors" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func newBufferedScrobbler(ds model.DataStore, s Scrobbler, service string) *bufferedScrobbler { + ctx, cancel := context.WithCancel(context.Background()) + b := &bufferedScrobbler{ + ds: ds, + wrapped: s, + service: service, + wakeSignal: make(chan struct{}, 1), + ctx: ctx, + cancel: cancel, + } + go b.run(ctx) + return b +} + +type bufferedScrobbler struct { + ds model.DataStore + wrapped Scrobbler + service string + wakeSignal chan struct{} + ctx context.Context + cancel context.CancelFunc +} + +func (b *bufferedScrobbler) Stop() { + if b.cancel != nil { + b.cancel() + } +} + +func (b *bufferedScrobbler) IsAuthorized(ctx context.Context, userId string) bool { + return b.wrapped.IsAuthorized(ctx, userId) +} + +func (b *bufferedScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { + return b.wrapped.NowPlaying(ctx, userId, track, position) +} + +func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { + err := b.ds.ScrobbleBuffer(ctx).Enqueue(b.service, userId, s.ID, s.TimeStamp) + if err != nil { + return err + } + + b.sendWakeSignal() + return nil +} + +func (b *bufferedScrobbler) sendWakeSignal() { + // Don't block if the previous signal was not read yet + select { + case b.wakeSignal <- struct{}{}: + default: + } +} + +func (b *bufferedScrobbler) run(ctx context.Context) { + for { + if !b.processQueue(ctx) { + time.AfterFunc(5*time.Second, func() { + b.sendWakeSignal() + }) + } + select { + case <-b.wakeSignal: + continue + case <-ctx.Done(): + return + } + } +} + +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 + } + result := true + for _, userId := range userIds { + if !b.processUserQueue(ctx, userId) { + result = false + } + } + return result +} + +func (b *bufferedScrobbler) processUserQueue(ctx context.Context, userId string) bool { + buffer := b.ds.ScrobbleBuffer(ctx) + for { + entry, err := buffer.Next(b.service, userId) + if err != nil { + log.Error(ctx, "Error reading from scrobble buffer", "scrobbler", b.service, err) + return false + } + if entry == nil { + return true + } + log.Debug(ctx, "Sending scrobble", "scrobbler", b.service, "track", entry.Title, "artist", entry.Artist) + err = b.wrapped.Scrobble(ctx, entry.UserID, Scrobble{ + MediaFile: entry.MediaFile, + TimeStamp: entry.PlayTime, + }) + 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 + } + 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(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 + } + } +} + +var _ Scrobbler = (*bufferedScrobbler)(nil) diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go new file mode 100644 index 0000000..9fbca6f --- /dev/null +++ b/core/scrobbler/buffered_scrobbler_test.go @@ -0,0 +1,89 @@ +package scrobbler + +import ( + "context" + "time" + + "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() + ds = &tests.MockDataStore{ + MockedScrobbleBuffer: buffer, + } + 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()).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("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") + } + }) +}) diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go new file mode 100644 index 0000000..f8567e9 --- /dev/null +++ b/core/scrobbler/interfaces.go @@ -0,0 +1,28 @@ +package scrobbler + +import ( + "context" + "errors" + "time" + + "github.com/navidrome/navidrome/model" +) + +type Scrobble struct { + model.MediaFile + TimeStamp time.Time +} + +var ( + ErrNotAuthorized = errors.New("not authorized") + 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 + Scrobble(ctx context.Context, userId string, s Scrobble) error +} + +type Constructor func(ds model.DataStore) Scrobbler diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go new file mode 100644 index 0000000..bac9d22 --- /dev/null +++ b/core/scrobbler/play_tracker.go @@ -0,0 +1,388 @@ +package scrobbler + +import ( + "context" + "maps" + "sort" + "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/server/events" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/singleton" +) + +type NowPlayingInfo struct { + MediaFile model.MediaFile + Start time.Time + Position int + Username string + PlayerId string + PlayerName string +} + +type Submission struct { + TrackID string + Timestamp time.Time +} + +type nowPlayingEntry struct { + userId string + track *model.MediaFile + position int +} + +type PlayTracker interface { + NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error + GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) + Submit(ctx context.Context, submissions []Submission) 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, NowPlayingInfo] + 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{} +} + +func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { + return singleton.GetInstance(func() *playTracker { + return newPlayTracker(ds, broker, pluginManager) + }) +} + +// 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, pluginManager PluginLoader) *playTracker { + m := cache.NewSimpleCache[string, NowPlayingInfo]() + 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{}), + } + if conf.Server.EnableNowPlaying { + m.OnExpiration(func(_ string, _ NowPlayingInfo) { + broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()}) + }) + } + + var enabled []string + for name, constructor := range constructors { + s := constructor(ds) + if s == nil { + log.Debug("Scrobbler not available. Missing configuration?", "name", name) + continue + } + enabled = append(enabled, name) + s = newBufferedScrobbler(ds, s, name) + p.builtinScrobblers[name] = s + } + log.Debug("List of builtin scrobblers enabled", "names", enabled) + go p.nowPlayingWorker() + return p +} + +// stopNowPlayingWorker stops the background worker. This is primarily for testing. +func (p *playTracker) stopNowPlayingWorker() { + close(p.shutdown) + <-p.workerDone // Wait for 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 +func (p *playTracker) refreshPluginScrobblers() { + p.mu.Lock() + defer p.mu.Unlock() + if p.pluginLoader == nil { + return + } + + // 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 + for _, name := range pluginNames { + current[name] = struct{}{} + // Only create a new scrobbler if it doesn't exist + if _, exists := p.pluginScrobblers[name]; !exists { + s, ok := p.pluginLoader.LoadScrobbler(name) + if ok && s != nil { + p.pluginScrobblers[name] = newBufferedScrobbler(p.ds, s, name) + } + } + } + + 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 +} + +func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) + if err != nil { + log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) + return err + } + + user, _ := request.UserFrom(ctx) + info := NowPlayingInfo{ + MediaFile: *mf, + Start: time.Now(), + Position: position, + Username: user.UserName, + PlayerId: playerId, + PlayerName: playerName, + } + + // Calculate TTL based on remaining track duration. If position exceeds track duration, + // remaining is set to 0 to avoid negative TTL. + remaining := int(mf.Duration) - position + if remaining < 0 { + remaining = 0 + } + // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. + ttl := time.Duration(remaining+5) * time.Second + _ = p.playMap.AddWithTTL(playerId, info, ttl) + if conf.Server.EnableNowPlaying { + p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) + } + player, _ := request.PlayerFrom(ctx) + if player.ScrobbleEnabled { + p.enqueueNowPlaying(playerId, user.ID, mf, position) + } + return nil +} + +func (p *playTracker) enqueueNowPlaying(playerId string, userId string, track *model.MediaFile, position int) { + p.npMu.Lock() + defer p.npMu.Unlock() + p.npQueue[playerId] = nowPlayingEntry{ + 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(context.Background(), entry.userId, entry.track, entry.position) + } + } +} + +func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) + return + } + allScrobblers := p.getActiveScrobblers() + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, userId) { + continue + } + log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) + err := s.NowPlaying(ctx, userId, t, position) + if err != nil { + log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) + continue + } + } +} + +func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { + res := p.playMap.Values() + sort.Slice(res, func(i, j int) bool { + return res[i].Start.After(res[j].Start) + }) + return res, nil +} + +func (p *playTracker) Submit(ctx context.Context, submissions []Submission) error { + 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) + } + event := &events.RefreshResource{} + success := 0 + + for _, s := range submissions { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(s.TrackID) + if err != nil { + log.Error(ctx, "Cannot find track for scrobbling", "id", s.TrackID, "user", username, err) + continue + } + 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) + } else { + success++ + 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) + } + } + } + + if success > 0 { + p.broker.SendMessage(ctx, event) + } + return nil +} + +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(ctx).IncPlayCount(track.ID, timestamp) + if err != nil { + return err + } + err = tx.Album(ctx).IncPlayCount(track.AlbumID, timestamp) + if err != nil { + return err + } + for _, artist := range track.Participants[model.RoleArtist] { + err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp) + if err != nil { + return err + } + } + if conf.Server.EnableScrobbleHistory { + return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp) + } + return nil + }) +} + +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 + } + + allScrobblers := p.getActiveScrobblers() + u, _ := request.UserFrom(ctx) + scrobble := Scrobble{MediaFile: *t, TimeStamp: playTime} + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, u.ID) { + continue + } + log.Debug(ctx, "Buffering 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) + continue + } + } +} + +var constructors map[string]Constructor + +func Register(name string, init Constructor) { + if constructors == nil { + constructors = make(map[string]Constructor) + } + constructors[name] = init +} diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go new file mode 100644 index 0000000..6f66276 --- /dev/null +++ b/core/scrobbler/play_tracker_test.go @@ -0,0 +1,535 @@ +package scrobbler + +import ( + "context" + "errors" + "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/request" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests +// Moved to top-level scope to avoid linter issues + +type mockPluginLoader struct { + mu sync.RWMutex + names []string + 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 +} + +var _ = Describe("PlayTracker", func() { + var ctx context.Context + var ds model.DataStore + var tracker PlayTracker + var eventBroker *fakeEventBroker + var track model.MediaFile + var album model.Album + var artist1 model.Artist + var artist2 model.Artist + var fake *fakeScrobbler + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = GinkgoT().Context() + 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 + }) + Register("disabled", func(model.DataStore) Scrobbler { + return nil + }) + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests + + track = model.MediaFile{ + ID: "123", + 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")}, + }, + } + _ = ds.MediaFile(ctx).Put(&track) + artist1 = model.Artist{ID: "ar-1"} + _ = ds.Artist(ctx).Put(&artist1) + artist2 = model.Artist{ID: "ar-2"} + _ = ds.Artist(ctx).Put(&artist2) + album = model.Album{ID: "al-1"} + _ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album) + }) + + AfterEach(func() { + // Stop the worker goroutine to prevent data races between tests + tracker.(*playTracker).stopNowPlayingWorker() + }) + + It("does not register disabled scrobblers", func() { + Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) + Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) + }) + + Describe("NowPlaying", func() { + It("sends track to agent", func() { + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + Expect(fake.GetUserID()).To(Equal("u-1")) + Expect(fake.GetTrack().ID).To(Equal("123")) + Expect(fake.GetTrack().Participants).To(Equal(track.Participants)) + }) + It("does not send track to agent if user has not authorized", func() { + fake.Authorized = false + + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.GetNowPlayingCalled()).To(BeFalse()) + }) + It("does not send track to agent if player is not enabled to send scrobbles", func() { + ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) + + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.GetNowPlayingCalled()).To(BeFalse()) + }) + It("does not send track to agent if artist is unknown", func() { + track.Artist = consts.UnknownArtist + + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.GetNowPlayingCalled()).To(BeFalse()) + }) + + It("stores position when greater than zero", func() { + pos := 42 + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos)) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].Position).To(Equal(pos)) + }) + + It("sends event with count", func() { + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + eventList := eventBroker.getEvents() + Expect(eventList).ToNot(BeEmpty()) + evt, ok := eventList[0].(*events.NowPlayingCount) + Expect(ok).To(BeTrue()) + Expect(evt.Count).To(Equal(1)) + }) + + It("does not send event when disabled", func() { + conf.Server.EnableNowPlaying = false + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) + }) + + Describe("GetNowPlaying", func() { + It("returns current playing music", func() { + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) + _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) + _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) + + playing, err := tracker.GetNowPlaying(ctx) + + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(2)) + Expect(playing[0].PlayerId).To(Equal("player-2")) + Expect(playing[0].PlayerName).To(Equal("player-two")) + Expect(playing[0].Username).To(Equal("user-2")) + Expect(playing[0].MediaFile.ID).To(Equal("456")) + + Expect(playing[1].PlayerId).To(Equal("player-1")) + Expect(playing[1].PlayerName).To(Equal("player-one")) + Expect(playing[1].Username).To(Equal("user-1")) + Expect(playing[1].MediaFile.ID).To(Equal("123")) + }) + }) + + Describe("Expiration events", func() { + It("sends event when entry expires", func() { + info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) + 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 := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) + Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) + }) + }) + + Describe("Submit", func() { + It("sends track to agent", func() { + 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()) + 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)) + }) + + It("increments play counts in the DB", func() { + 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()) + 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))) + }) + + It("does not send track to agent if user has not authorized", func() { + fake.Authorized = false + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).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.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + + It("does not send track to agent if artist is unknown", func() { + track.Artist = consts.UnknownArtist + + err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: time.Now()}}) + + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeFalse()) + }) + + It("increments play counts even if it cannot scrobble", func() { + 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(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))) + }) + + 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(ctx).(*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)) + }) + + 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(ctx).(*tests.MockScrobbleRepo) + Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0)) + }) + }) + }) + + 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.(*playTracker).builtinScrobblers["fake"] = fake + tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake + }) + + It("registers and uses plugin scrobbler for NowPlaying", func() { + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("removes plugin scrobbler if not present anymore", func() { + // First call: plugin present + _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) + pluginFake.nowPlayingCalled.Store(false) + // Remove plugin + pluginLoader.SetNames([]string{}) + _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + // Should not be called since plugin was removed + Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("calls both builtin and plugin scrobblers for NowPlaying", func() { + fake.nowPlayingCalled.Store(false) + pluginFake.nowPlayingCalled.Store(false) + err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + 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")) + }) + }) +}) + +type fakeScrobbler struct { + Authorized bool + nowPlayingCalled atomic.Bool + ScrobbleCalled atomic.Bool + userID atomic.Pointer[string] + track atomic.Pointer[model.MediaFile] + position atomic.Int32 + LastScrobble atomic.Pointer[Scrobble] + Error error +} + +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) GetTrack() *model.MediaFile { + return f.track.Load() +} + +func (f *fakeScrobbler) GetPosition() int { + return int(f.position.Load()) +} + +func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { + 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 f.Error != nil { + return f.Error + } + f.userID.Store(&userId) + f.track.Store(track) + f.position.Store(int32(position)) + return nil +} + +func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { + f.userID.Store(&userId) + f.LastScrobble.Store(&s) + f.ScrobbleCalled.Store(true) + if f.Error != nil { + return f.Error + } + 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) +} diff --git a/core/scrobbler/scrobbler_suite_test.go b/core/scrobbler/scrobbler_suite_test.go new file mode 100644 index 0000000..9ec3ba1 --- /dev/null +++ b/core/scrobbler/scrobbler_suite_test.go @@ -0,0 +1,17 @@ +package scrobbler + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAgents(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Scrobbler Test Suite") +} diff --git a/core/share.go b/core/share.go new file mode 100644 index 0000000..eb5e667 --- /dev/null +++ b/core/share.go @@ -0,0 +1,202 @@ +package core + +import ( + "context" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" +) + +type Share interface { + Load(ctx context.Context, id string) (*model.Share, error) + NewRepository(ctx context.Context) rest.Repository +} + +func NewShare(ds model.DataStore) Share { + return &shareService{ + ds: ds, + } +} + +type shareService struct { + ds model.DataStore +} + +func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error) { + 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()) { + return nil, model.ErrExpired + } + share.LastVisitedAt = P(time.Now()) + share.VisitCount++ + + 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) 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 + rest.Repository + rest.Persistable + ctx context.Context + ds model.DataStore +} + +func (r *shareRepositoryWrapper) newId() (string, error) { + for { + id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 10) + if err != nil { + return "", err + } + exists, err := r.Exists(id) + if err != nil { + return "", err + } + if !exists { + return id, nil + } + } +} + +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 = P(time.Now().Add(conf.Server.DefaultShareExpiration)) + } + + firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] + v, err := model.GetEntityByID(r.ctx, r.ds, firstId) + if err != nil { + return "", err + } + 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 + } + + s.Contents = str.TruncateRunes(s.Contents, 30, "...") + + id, err = r.Persistable.Save(s) + return id, err +} + +func (r *shareRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error { + cols := []string{"description", "downloadable"} + + // TODO Better handling of Share expiration + if !V(entity.(*model.Share).ExpiresAt).IsZero() { + cols = append(cols, "expires_at") + } + return r.Persistable.Update(id, entity, cols...) +} + +func (r *shareRepositoryWrapper) contentsLabelFromArtist(shareID string, ids string) string { + idList := strings.SplitN(ids, ",", 2) + a, err := r.ds.Artist(r.ctx).Get(idList[0]) + if err != nil { + log.Error(r.ctx, "Error retrieving artist name for share", "share", shareID, err) + return "" + } + return a.Name +} + +func (r *shareRepositoryWrapper) contentsLabelFromAlbums(shareID string, ids string) string { + idList := strings.Split(ids, ",") + all, err := r.ds.Album(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"album.id": idList}}) + if err != nil { + 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(shareID string, id string) string { + pls, err := r.ds.Playlist(r.ctx).Get(id) + if err != nil { + log.Error(r.ctx, "Error retrieving album names for share", "share", shareID, err) + return "" + } + return pls.Name +} + +func (r *shareRepositoryWrapper) contentsLabelFromMediaFiles(shareID string, ids string) string { + idList := strings.Split(ids, ",") + mfs, err := r.ds.MediaFile(r.ctx).GetAll(model.QueryOptions{Filters: squirrel.And{ + squirrel.Eq{"media_file.id": idList}, + squirrel.Eq{"missing": false}, + }}) + if err != nil { + log.Error(r.ctx, "Error retrieving media files for share", "share", shareID, err) + return "" + } + + if len(mfs) == 1 { + return mfs[0].Title + } + + albums := slice.Group(mfs, func(mf model.MediaFile) string { + return mf.Album + }) + if len(albums) == 1 { + for name := range albums { + return name + } + } + artists := slice.Group(mfs, func(mf model.MediaFile) string { + return mf.AlbumArtist + }) + if len(artists) == 1 { + for name := range artists { + return name + } + } + + return mfs[0].Title +} diff --git a/core/share_test.go b/core/share_test.go new file mode 100644 index 0000000..475d40e --- /dev/null +++ b/core/share_test.go @@ -0,0 +1,84 @@ +package core + +import ( + "context" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Share", func() { + var ds model.DataStore + var share Share + var mockedRepo rest.Persistable + ctx := context.Background() + + BeforeEach(func() { + ds = &tests.MockDataStore{} + mockedRepo = ds.Share(ctx).(rest.Persistable) + share = NewShare(ds) + }) + + Describe("NewRepository", func() { + var repo rest.Persistable + + BeforeEach(func() { + 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(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeEmpty()) + Expect(entity.ID).To(Equal(id)) + }) + + It("does not truncate ASCII labels shorter than 30 characters", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"}) + entity := &model.Share{Description: "test", ResourceIDs: "456"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("Example Media File")) + }) + + It("truncates ASCII labels longer than 30 characters", func() { + _ = ds.MediaFile(ctx).Put(&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(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(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"}) + entity := &model.Share{Description: "test", ResourceIDs: "456"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("青春コンプレックス")) + }) + + It("truncates CJK labels longer than 30 runes", func() { + _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"}) + entity := &model.Share{Description: "test", ResourceIDs: "789"} + _, err := repo.Save(entity) + Expect(err).ToNot(HaveOccurred()) + Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で...")) + }) + }) + + Describe("Update", func() { + It("filters out read-only fields", func() { + 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/storage/interface.go b/core/storage/interface.go new file mode 100644 index 0000000..dc08ca0 --- /dev/null +++ b/core/storage/interface.go @@ -0,0 +1,25 @@ +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) +} + +// 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/extractors.go b/core/storage/local/extractors.go new file mode 100644 index 0000000..654e71c --- /dev/null +++ b/core/storage/local/extractors.go @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..5c335dd --- /dev/null +++ b/core/storage/local/local.go @@ -0,0 +1,91 @@ +package local + +import ( + "fmt" + "io/fs" + "net/url" + "os" + "path/filepath" + "sync/atomic" + "time" + + "github.com/djherbis/times" + "github.com/navidrome/navidrome/conf" + "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 { + log.Fatal("Extractor not found", "path", conf.Server.Scanner.Extractor) + } + 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 { + return nil, fmt.Errorf("%w: %s", err, path) + } + return &localFS{FS: os.DirFS(path), extractor: s.extractor}, nil +} + +type localFS struct { + fs.FS + extractor Extractor +} + +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{info} + 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 +} + +func (lfi localFileInfo) BirthTime() time.Time { + if ts := times.Get(lfi.FileInfo); ts.HasBirthTime() { + return ts.BirthTime() + } + return time.Now() +} + +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 new file mode 100644 index 0000000..5934cde --- /dev/null +++ b/core/storage/local/local_suite_test.go @@ -0,0 +1,17 @@ +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 new file mode 100644 index 0000000..3ed01bb --- /dev/null +++ b/core/storage/local/local_test.go @@ -0,0 +1,428 @@ +package local + +import ( + "io/fs" + "net/url" + "os" + "path/filepath" + "runtime" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "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 := url.Parse("file://" + 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 := url.Parse("file://" + 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 := url.Parse("file://" + 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()) + + Expect(localStorage.u.Path).To(Equal("C:/music")) + }) + }) + + Context("with invalid extractor", func() { + It("should handle extractor validation correctly", func() { + // Note: The actual implementation uses log.Fatal which exits the process, + // so we test the normal path where extractors exist + + u, err := url.Parse("file://" + tempDir) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + Expect(storage).ToNot(BeNil()) + }) + }) + }) + + Describe("localStorage.FS", func() { + Context("with existing directory", func() { + It("should return a localFS instance", func() { + u, err := url.Parse("file://" + 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 := url.Parse("file://" + nonExistentPath) + Expect(err).ToNot(HaveOccurred()) + + storage := newLocalStorage(*u) + _, err = storage.FS() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(nonExistentPath)) + }) + }) + }) + + 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 := url.Parse("file://" + 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 := url.Parse("file://" + 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 := url.Parse("file://" + 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 := url.Parse("file://" + 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 := url.Parse("file://" + 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("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("file://" + 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 new file mode 100644 index 0000000..6767b3f --- /dev/null +++ b/core/storage/local/watch_events_darwin.go @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..e36bc40 --- /dev/null +++ b/core/storage/local/watch_events_default.go @@ -0,0 +1,7 @@ +//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 new file mode 100644 index 0000000..68fd8aa --- /dev/null +++ b/core/storage/local/watch_events_linux.go @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..c1b94cf --- /dev/null +++ b/core/storage/local/watch_events_windows.go @@ -0,0 +1,5 @@ +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 new file mode 100644 index 0000000..e2418f4 --- /dev/null +++ b/core/storage/local/watcher.go @@ -0,0 +1,57 @@ +package local + +import ( + "context" + "errors" + "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, 1) + output := make(chan string, 1) + + started := make(chan struct{}) + go func() { + defer close(input) + defer close(output) + + libPath := filepath.Join(s.u.Path, "...") + log.Debug(ctx, "Starting watcher", "lib", libPath) + err := notify.Watch(libPath, input, WatchEvents) + if err != nil { + log.Error("Error starting watcher", "lib", libPath, err) + return + } + defer notify.Stop(input) + close(started) // signals the main goroutine we have started + + for { + select { + 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 + } + } + }() + select { + case <-started: + case <-ctx.Done(): + } + return output, nil +} diff --git a/core/storage/local/watcher_test.go b/core/storage/local/watcher_test.go new file mode 100644 index 0000000..8d2d313 --- /dev/null +++ b/core/storage/local/watcher_test.go @@ -0,0 +1,139 @@ +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" } diff --git a/core/storage/storage.go b/core/storage/storage.go new file mode 100644 index 0000000..b9fceb1 --- /dev/null +++ b/core/storage/storage.go @@ -0,0 +1,60 @@ +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 +} + +// 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, "://") + + // Paths without schema are treated as file:// and use the default LocalStorage implementation + if len(parts) < 2 { + uri, _ = filepath.Abs(uri) + uri = filepath.ToSlash(uri) + + // Properly escape each path component using URL standards + pathParts := strings.Split(uri, "/") + escapedParts := slice.Map(pathParts, func(s string) string { + return url.PathEscape(s) + }) + + uri = LocalSchemaID + "://" + strings.Join(escapedParts, "/") + } + + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + 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 new file mode 100644 index 0000000..60496e6 --- /dev/null +++ b/core/storage/storage_test.go @@ -0,0 +1,93 @@ +package storage + +import ( + "net/url" + "os" + "path/filepath" + "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{})) + Expect(s.(*fakeLocalStorage).u.Scheme).To(Equal("file")) + Expect(s.(*fakeLocalStorage).u.Path).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"), + ) + }) +}) + +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 new file mode 100644 index 0000000..009b37d --- /dev/null +++ b/core/storage/storagetest/fake_storage.go @@ -0,0 +1,323 @@ +//nolint:unused +package storagetest + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "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) + } + for k, v := range newTags { + tags[k] = v + } + 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 { + for k, v := range t { + ts[k] = v + } + } + 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 { + for k, v := range t { + ts[k] = v + } + } + 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") + 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 new file mode 100644 index 0000000..46deb77 --- /dev/null +++ b/core/storage/storagetest/fake_storage_test.go @@ -0,0 +1,139 @@ +//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/wire_providers.go b/core/wire_providers.go new file mode 100644 index 0000000..1633564 --- /dev/null +++ b/core/wire_providers.go @@ -0,0 +1,29 @@ +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/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/scrobbler" +) + +var Set = wire.NewSet( + NewMediaStreamer, + GetTranscodingCache, + NewArchiver, + NewPlayers, + NewShare, + NewPlaylists, + NewLibrary, + NewMaintenance, + agents.GetAgents, + external.NewProvider, + wire.Bind(new(external.Agents), new(*agents.Agents)), + ffmpeg.New, + scrobbler.GetPlayTracker, + playback.GetInstance, + metrics.GetInstance, +) diff --git a/db/backup.go b/db/backup.go new file mode 100644 index 0000000..8b0f18b --- /dev/null +++ b/db/backup.go @@ -0,0 +1,167 @@ +package db + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "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, + 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() + + 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 !done { + return fmt.Errorf("backup not done with step -1") + } + if err != nil { + return fmt.Errorf("error during backup step: %w", err) + } + + 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) { + files, err := os.ReadDir(conf.Server.Backup.Path) + 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 new file mode 100644 index 0000000..aec4344 --- /dev/null +++ b/db/backup_test.go @@ -0,0 +1,150 @@ +package db_test + +import ( + "context" + "database/sql" + "math/rand" + "os" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/tests" + . "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 = tempFolder + + DeferCleanup(func() { + _ = os.RemoveAll(tempFolder) + }) + + timesShuffled = make([]time.Time, len(timesDecreasingChronologically)) + copy(timesShuffled, timesDecreasingChronologically) + 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))) + }) + + 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 = 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()) + }) + }) +}) diff --git a/db/db.go b/db/db.go new file mode 100644 index 0000000..bc29d52 --- /dev/null +++ b/db/db.go @@ -0,0 +1,209 @@ +package db + +import ( + "context" + "database/sql" + "embed" + "fmt" + "runtime" + + "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/conf" + _ "github.com/navidrome/navidrome/db/migrations" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/hasher" + "github.com/navidrome/navidrome/utils/singleton" + "github.com/pressly/goose/v3" +) + +var ( + Dialect = "sqlite3" + Driver = Dialect + "_custom" + Path string +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +const migrationsFolder = "migrations" + +func Db() *sql.DB { + return singleton.GetInstance(func() *sql.DB { + sql.Register(Driver, &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) error { + return conn.RegisterFunc("SEEDEDRAND", hasher.HashFunc(), false) + }, + }) + Path = conf.Server.DbPath + if Path == ":memory:" { + Path = "file::memory:?cache=shared&_foreign_keys=on" + conf.Server.DbPath = Path + } + log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) + db, err := sql.Open(Driver, Path) + db.SetMaxOpenConns(max(4, runtime.NumCPU())) + if err != nil { + log.Fatal("Error opening database", err) + } + if conf.Server.DevOptimizeDB { + _, err = db.Exec("PRAGMA optimize=0x10002") + if err != nil { + log.Error("Error applying PRAGMA optimize", err) + } + } + return db + }) +} + +func Close(ctx context.Context) { + // Ignore cancellations when closing the DB + ctx = context.WithoutCancel(ctx) + + // Run optimize before closing + Optimize(ctx) + + log.Info(ctx, "Closing Database") + err := Db().Close() + if err != nil { + log.Error(ctx, "Error closing Database", err) + } +} + +func Init(ctx context.Context) func() { + db := Db() + + // Disable foreign_keys to allow re-creating tables in migrations + _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=off") + defer func() { + _, err := db.ExecContext(ctx, "PRAGMA foreign_keys=on") + if err != nil { + log.Error(ctx, "Error re-enabling foreign_keys", err) + } + }() + if err != nil { + log.Error(ctx, "Error disabling foreign_keys", err) + } + + goose.SetBaseFS(embedMigrations) + err = goose.SetDialect(Dialect) + if err != nil { + log.Fatal(ctx, "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) + if err != nil { + log.Fatal(ctx, "Failed to apply new migrations", err) + } + + if hasSchemaChanges && conf.Server.DevOptimizeDB { + log.Debug(ctx, "Applying PRAGMA optimize after schema changes") + _, err = db.ExecContext(ctx, "PRAGMA optimize") + if err != nil { + log.Error(ctx, "Error applying PRAGMA optimize", err) + } + } + + return func() { + Close(ctx) + } +} + +// Optimize runs PRAGMA optimize on each connection in the pool +func Optimize(ctx context.Context) { + if !conf.Server.DevOptimizeDB { + return + } + numConns := Db().Stats().OpenConnections + if numConns == 0 { + log.Debug(ctx, "No open connections to optimize") + return + } + log.Debug(ctx, "Optimizing open connections", "numConns", numConns) + var conns []*sql.Conn + for i := 0; i < numConns; i++ { + conn, err := Db().Conn(ctx) + conns = append(conns, conn) + if err != nil { + log.Error(ctx, "Error getting connection from pool", err) + continue + } + _, err = conn.ExecContext(ctx, "PRAGMA optimize;") + if err != nil { + log.Error(ctx, "Error running PRAGMA optimize", err) + } + } + + // Return all connections to the Connection Pool + for _, conn := range conns { + conn.Close() + } +} + +type statusLogger struct{ numPending int } + +func (*statusLogger) Fatalf(format string, v ...interface{}) { log.Fatal(fmt.Sprintf(format, v...)) } +func (l *statusLogger) Printf(format string, v ...interface{}) { + 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) + if err != nil { + log.Fatal(ctx, "Failed to check for pending migrations", err) + } + return l.numPending > 0 +} + +func isSchemaEmpty(ctx context.Context, db *sql.DB) bool { + rows, err := db.QueryContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='goose_db_version';") // nolint:rowserrcheck + if err != nil { + log.Fatal(ctx, "Database could not be opened!", err) + } + defer rows.Close() + return !rows.Next() +} + +type logAdapter struct { + ctx context.Context + silent bool +} + +func (l *logAdapter) Fatal(v ...interface{}) { + log.Fatal(l.ctx, fmt.Sprint(v...)) +} + +func (l *logAdapter) Fatalf(format string, v ...interface{}) { + log.Fatal(l.ctx, fmt.Sprintf(format, v...)) +} + +func (l *logAdapter) Print(v ...interface{}) { + if !l.silent { + log.Info(l.ctx, fmt.Sprint(v...)) + } +} + +func (l *logAdapter) Println(v ...interface{}) { + if !l.silent { + log.Info(l.ctx, fmt.Sprintln(v...)) + } +} + +func (l *logAdapter) Printf(format string, v ...interface{}) { + if !l.silent { + log.Info(l.ctx, fmt.Sprintf(format, v...)) + } +} diff --git a/db/db_test.go b/db/db_test.go new file mode 100644 index 0000000..2ce01dc --- /dev/null +++ b/db/db_test.go @@ -0,0 +1,40 @@ +package db_test + +import ( + "context" + "database/sql" + "testing" + + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDB(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "DB Suite") +} + +var _ = Describe("IsSchemaEmpty", func() { + var database *sql.DB + var ctx context.Context + BeforeEach(func() { + ctx = context.Background() + path := "file::memory:" + database, _ = sql.Open(db.Dialect, path) + }) + + It("returns false if the goose metadata table is found", func() { + _, err := database.Exec("create table goose_db_version (id primary key);") + Expect(err).ToNot(HaveOccurred()) + Expect(db.IsSchemaEmpty(ctx, database)).To(BeFalse()) + }) + + It("returns true if the schema is brand new", func() { + Expect(db.IsSchemaEmpty(ctx, database)).To(BeTrue()) + }) +}) diff --git a/db/export_test.go b/db/export_test.go new file mode 100644 index 0000000..734a446 --- /dev/null +++ b/db/export_test.go @@ -0,0 +1,7 @@ +package db + +// Definitions for testing private methods +var ( + IsSchemaEmpty = isSchemaEmpty + BackupPath = backupPath +) diff --git a/db/migrations/20200130083147_create_schema.go b/db/migrations/20200130083147_create_schema.go new file mode 100644 index 0000000..2fae4f5 --- /dev/null +++ b/db/migrations/20200130083147_create_schema.go @@ -0,0 +1,184 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/navidrome/navidrome/log" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200130083147, Down20200130083147) +} + +func Up20200130083147(_ context.Context, tx *sql.Tx) error { + log.Info("Creating DB Schema") + _, err := tx.Exec(` +create table if not exists album +( + id varchar(255) not null + primary key, + name varchar(255) default '' not null, + artist_id varchar(255) default '' not null, + cover_art_path varchar(255) default '' not null, + cover_art_id varchar(255) default '' not null, + artist varchar(255) default '' not null, + album_artist varchar(255) default '' not null, + year integer default 0 not null, + compilation bool default FALSE not null, + song_count integer default 0 not null, + duration integer default 0 not null, + genre varchar(255) default '' not null, + created_at datetime, + updated_at datetime +); + +create index if not exists album_artist + on album (artist); + +create index if not exists album_artist_id + on album (artist_id); + +create index if not exists album_genre + on album (genre); + +create index if not exists album_name + on album (name); + +create index if not exists album_year + on album (year); + +create table if not exists annotation +( + ann_id varchar(255) not null + primary key, + user_id varchar(255) default '' not null, + item_id varchar(255) default '' not null, + item_type varchar(255) default '' not null, + play_count integer, + play_date datetime, + rating integer, + starred bool default FALSE not null, + starred_at datetime, + unique (user_id, item_id, item_type) +); + +create index if not exists annotation_play_count + on annotation (play_count); + +create index if not exists annotation_play_date + on annotation (play_date); + +create index if not exists annotation_rating + on annotation (rating); + +create index if not exists annotation_starred + on annotation (starred); + +create table if not exists artist +( + id varchar(255) not null + primary key, + name varchar(255) default '' not null, + album_count integer default 0 not null +); + +create index if not exists artist_name + on artist (name); + +create table if not exists media_file +( + 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 integer 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 +); + +create index if not exists media_file_album_id + on media_file (album_id); + +create index if not exists media_file_genre + on media_file (genre); + +create index if not exists media_file_path + on media_file (path); + +create index if not exists media_file_title + on media_file (title); + +create table if not exists playlist +( + id varchar(255) not null + primary key, + name varchar(255) default '' not null, + comment varchar(255) default '' not null, + duration integer default 0 not null, + owner varchar(255) default '' not null, + public bool default FALSE not null, + tracks text not null +); + +create index if not exists playlist_name + on playlist (name); + +create table if not exists property +( + id varchar(255) not null + primary key, + value varchar(255) default '' not null +); + +create table if not exists search +( + id varchar(255) not null + primary key, + "table" varchar(255) default '' not null, + full_text varchar(255) default '' not null +); + +create index if not exists search_full_text + on search (full_text); + +create index if not exists search_table + on search ("table"); + +create table if not exists user +( + id varchar(255) not null + primary key, + user_name varchar(255) default '' not null + unique, + name varchar(255) default '' not null, + email varchar(255) default '' not null + unique, + 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 +);`) + return err +} + +func Down20200130083147(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200131183653_standardize_item_type.go b/db/migrations/20200131183653_standardize_item_type.go new file mode 100644 index 0000000..471dc80 --- /dev/null +++ b/db/migrations/20200131183653_standardize_item_type.go @@ -0,0 +1,64 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200131183653, Down20200131183653) +} + +func Up20200131183653(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table search_dg_tmp +( + id varchar(255) not null + primary key, + item_type varchar(255) default '' not null, + full_text varchar(255) default '' not null +); + +insert into search_dg_tmp(id, item_type, full_text) select id, "table", full_text from search; + +drop table search; + +alter table search_dg_tmp rename to search; + +create index search_full_text + on search (full_text); +create index search_table + on search (item_type); + +update annotation set item_type = 'media_file' where item_type = 'mediaFile'; +`) + return err +} + +func Down20200131183653(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table search_dg_tmp +( + id varchar(255) not null + primary key, + "table" varchar(255) default '' not null, + full_text varchar(255) default '' not null +); + +insert into search_dg_tmp(id, "table", full_text) select id, item_type, full_text from search; + +drop table search; + +alter table search_dg_tmp rename to search; + +create index search_full_text + on search (full_text); +create index search_table + on search ("table"); + +update annotation set item_type = 'mediaFile' where item_type = 'media_file'; +`) + return err +} diff --git a/db/migrations/20200208222418_add_defaults_to_annotations.go b/db/migrations/20200208222418_add_defaults_to_annotations.go new file mode 100644 index 0000000..d058b02 --- /dev/null +++ b/db/migrations/20200208222418_add_defaults_to_annotations.go @@ -0,0 +1,56 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200208222418, Down20200208222418) +} + +func Up20200208222418(_ context.Context, 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 +( + ann_id varchar(255) not null + primary key, + 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(ann_id, user_id, item_id, item_type, play_count, play_date, rating, starred, starred_at) select ann_id, 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); +`) + return err +} + +func Down20200208222418(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200220143731_change_duration_to_float.go b/db/migrations/20200220143731_change_duration_to_float.go new file mode 100644 index 0000000..72b785e --- /dev/null +++ b/db/migrations/20200220143731_change_duration_to_float.go @@ -0,0 +1,130 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200220143731, Down20200220143731) +} + +func Up20200220143731(_ context.Context, 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 + 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 +); + +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) 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 from media_file; + +drop table media_file; + +alter table media_file_dg_tmp rename to media_file; + +create index media_file_album_id + on media_file (album_id); + +create index media_file_genre + on media_file (genre); + +create index media_file_path + on media_file (path); + +create index media_file_title + on media_file (title); + +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, + cover_art_path varchar(255) default '' not null, + cover_art_id varchar(255) default '' not null, + artist varchar(255) default '' not null, + album_artist varchar(255) default '' not null, + 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 +); + +insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at from album; + +drop table album; + +alter table album_dg_tmp rename to album; + +create index album_artist + on album (artist); + +create index album_artist_id + on album (artist_id); + +create index album_genre + on album (genre); + +create index album_name + on album (name); + +create index album_year + on album (year); + +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, + owner varchar(255) default '' not null, + public bool default FALSE not null, + tracks text not null +); + +insert into playlist_dg_tmp(id, name, comment, duration, owner, public, tracks) select id, name, comment, duration, owner, public, tracks from playlist; + +drop table playlist; + +alter table playlist_dg_tmp rename to playlist; + +create index playlist_name + on playlist (name); + +-- Force a full rescan +delete from property where id like 'LastScan%'; +update media_file set updated_at = '0001-01-01'; +`) + return err +} + +func Down20200220143731(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200310171621_enable_search_by_albumartist.go b/db/migrations/20200310171621_enable_search_by_albumartist.go new file mode 100644 index 0000000..373e0a4 --- /dev/null +++ b/db/migrations/20200310171621_enable_search_by_albumartist.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200310171621, Down20200310171621) +} + +func Up20200310171621(_ context.Context, tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to enable search by Album Artist!") + return forceFullRescan(tx) +} + +func Down20200310171621(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200310181627_add_transcoding_and_player_tables.go b/db/migrations/20200310181627_add_transcoding_and_player_tables.go new file mode 100644 index 0000000..3be91ac --- /dev/null +++ b/db/migrations/20200310181627_add_transcoding_and_player_tables.go @@ -0,0 +1,54 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200310181627, Down20200310181627) +} + +func Up20200310181627(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table transcoding +( + id varchar(255) not null primary key, + name varchar(255) not null, + target_format varchar(255) not null, + command varchar(255) default '' not null, + default_bit_rate int default 192, + unique (name), + unique (target_format) +); + +create table player +( + id varchar(255) not null primary key, + name varchar not null, + type varchar, + user_name varchar not null, + client varchar not null, + ip_address varchar, + last_seen timestamp, + max_bit_rate int default 0, + transcoding_id varchar, + unique (name), + foreign key (transcoding_id) + references transcoding(id) + on update restrict + on delete restrict +); +`) + return err +} + +func Down20200310181627(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +drop table transcoding; +drop table player; +`) + return err +} diff --git a/db/migrations/20200319211049_merge_search_into_main_tables.go b/db/migrations/20200319211049_merge_search_into_main_tables.go new file mode 100644 index 0000000..f888cdd --- /dev/null +++ b/db/migrations/20200319211049_merge_search_into_main_tables.go @@ -0,0 +1,42 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200319211049, Down20200319211049) +} + +func Up20200319211049(_ context.Context, 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 + on media_file (full_text); + +alter table album + add full_text varchar(255) default ''; +create index if not exists album_full_text + on album (full_text); + +alter table artist + add full_text varchar(255) default ''; +create index if not exists artist_full_text + on artist (full_text); + +drop table if exists search; +`) + if err != nil { + return err + } + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) +} + +func Down20200319211049(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200325185135_add_album_artist_id.go b/db/migrations/20200325185135_add_album_artist_id.go new file mode 100644 index 0000000..f01f2c5 --- /dev/null +++ b/db/migrations/20200325185135_add_album_artist_id.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200325185135, Down20200325185135) +} + +func Up20200325185135(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table album + add album_artist_id varchar(255) default ''; +create index album_artist_album_id + on album (album_artist_id); + +alter table media_file + add album_artist_id varchar(255) default ''; +create index media_file_artist_album_id + on media_file (album_artist_id); +`) + if err != nil { + return err + } + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) +} + +func Down20200325185135(_ context.Context, tx *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 new file mode 100644 index 0000000..c42e8c3 --- /dev/null +++ b/db/migrations/20200326090707_fix_album_artists_importing.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200326090707, Down20200326090707) +} + +func Up20200326090707(_ context.Context, tx *sql.Tx) error { + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) +} + +func Down20200326090707(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200327193744_add_year_range_to_album.go b/db/migrations/20200327193744_add_year_range_to_album.go new file mode 100644 index 0000000..66f2b23 --- /dev/null +++ b/db/migrations/20200327193744_add_year_range_to_album.go @@ -0,0 +1,81 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200327193744, Down20200327193744) +} + +func Up20200327193744(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +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, + cover_art_path varchar(255) default '' not null, + cover_art_id 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 '' +); + +insert into album_dg_tmp(id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, max_year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id) select id, name, artist_id, cover_art_path, cover_art_id, artist, album_artist, year, compilation, song_count, duration, genre, created_at, updated_at, full_text, album_artist_id from album; + +drop table album; + +alter table album_dg_tmp rename to album; + +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_full_text + on album (full_text); + +create index album_genre + on album (genre); + +create index album_name + on album (name); + +create index album_min_year + on album (min_year); + +create index album_max_year + on album (max_year); + +`) + if err != nil { + return err + } + notice(tx, "A full rescan will be performed!") + return forceFullRescan(tx) +} + +func Down20200327193744(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200404214704_add_indexes.go b/db/migrations/20200404214704_add_indexes.go new file mode 100644 index 0000000..6207b0a --- /dev/null +++ b/db/migrations/20200404214704_add_indexes.go @@ -0,0 +1,30 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200404214704, Down20200404214704) +} + +func Up20200404214704(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create index if not exists media_file_year + on media_file (year); + +create index if not exists media_file_duration + on media_file (duration); + +create index if not exists media_file_track_number + on media_file (disc_number, track_number); +`) + return err +} + +func Down20200404214704(_ context.Context, tx *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 new file mode 100644 index 0000000..22006c8 --- /dev/null +++ b/db/migrations/20200409002249_enable_search_by_tracks_artists.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200409002249, Down20200409002249) +} + +func Up20200409002249(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go new file mode 100644 index 0000000..266dc08 --- /dev/null +++ b/db/migrations/20200411164603_add_created_and_updated_fields_to_playlists.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200411164603, Down20200411164603) +} + +func Up20200411164603(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table playlist + add created_at datetime; +alter table playlist + add updated_at datetime; +update playlist + set created_at = datetime('now'), updated_at = datetime('now'); +`) + return err +} + +func Down20200411164603(_ context.Context, tx *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 new file mode 100644 index 0000000..22b024c --- /dev/null +++ b/db/migrations/20200418110522_reindex_to_fix_album_years.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200418110522, Down20200418110522) +} + +func Up20200418110522(_ context.Context, tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to fix search Albums by year") + return forceFullRescan(tx) +} + +func Down20200418110522(_ context.Context, tx *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 new file mode 100644 index 0000000..efeb1bb --- /dev/null +++ b/db/migrations/20200419222708_reindex_to_change_full_text_search.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200419222708, Down20200419222708) +} + +func Up20200419222708(_ context.Context, tx *sql.Tx) error { + notice(tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(tx) +} + +func Down20200419222708(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200423204116_add_sort_fields.go b/db/migrations/20200423204116_add_sort_fields.go new file mode 100644 index 0000000..4097a9d --- /dev/null +++ b/db/migrations/20200423204116_add_sort_fields.go @@ -0,0 +1,66 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200423204116, Down20200423204116) +} + +func Up20200423204116(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table artist + add order_artist_name varchar(255) collate nocase; +alter table artist + add sort_artist_name varchar(255) collate nocase; +create index if not exists artist_order_artist_name + on artist (order_artist_name); + +alter table album + add order_album_name varchar(255) collate nocase; +alter table album + add order_album_artist_name varchar(255) collate nocase; +alter table album + add sort_album_name varchar(255) collate nocase; +alter table album + add sort_artist_name varchar(255) collate nocase; +alter table album + add sort_album_artist_name varchar(255) collate nocase; +create index if not exists album_order_album_name + on album (order_album_name); +create index if not exists album_order_album_artist_name + on album (order_album_artist_name); + +alter table media_file + add order_album_name varchar(255) collate nocase; +alter table media_file + add order_album_artist_name varchar(255) collate nocase; +alter table media_file + add order_artist_name varchar(255) collate nocase; +alter table media_file + add sort_album_name varchar(255) collate nocase; +alter table media_file + add sort_artist_name varchar(255) collate nocase; +alter table media_file + add sort_album_artist_name varchar(255) collate nocase; +alter table media_file + add sort_title varchar(255) collate nocase; +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); +`) + if err != nil { + return err + } + notice(tx, "A full rescan will be performed to change the search behaviour") + return forceFullRescan(tx) +} + +func Down20200423204116(_ context.Context, tx *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 new file mode 100644 index 0000000..aac78e6 --- /dev/null +++ b/db/migrations/20200508093059_add_artist_song_count.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200508093059, Down20200508093059) +} + +func Up20200508093059(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200512104202_add_disc_subtitle.go b/db/migrations/20200512104202_add_disc_subtitle.go new file mode 100644 index 0000000..b3e907d --- /dev/null +++ b/db/migrations/20200512104202_add_disc_subtitle.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200512104202, Down20200512104202) +} + +func Up20200512104202(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200516140647_add_playlist_tracks_table.go b/db/migrations/20200516140647_add_playlist_tracks_table.go new file mode 100644 index 0000000..fcaae9d --- /dev/null +++ b/db/migrations/20200516140647_add_playlist_tracks_table.go @@ -0,0 +1,101 @@ +package migrations + +import ( + "context" + "database/sql" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200516140647, Down20200516140647) +} + +func Up20200516140647(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table if not exists playlist_tracks +( + id integer default 0 not null, + playlist_id varchar(255) not null, + media_file_id varchar(255) not null +); + +create unique index if not exists playlist_tracks_pos + on playlist_tracks (playlist_id, id); +`) + if err != nil { + return err + } + rows, err := tx.Query("select id, tracks from playlist") + if err != nil { + return err + } + defer rows.Close() + var id, tracks string + for rows.Next() { + err := rows.Scan(&id, &tracks) + if err != nil { + return err + } + err = Up20200516140647UpdatePlaylistTracks(tx, id, tracks) + if err != nil { + return err + } + } + err = rows.Err() + if err != nil { + return err + } + + _, err = tx.Exec(` +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, + owner varchar(255) default '' not null, + public bool default FALSE not null, + created_at datetime, + updated_at datetime +); + +insert into playlist_dg_tmp(id, name, comment, duration, owner, public, created_at, updated_at) + select id, name, comment, duration, owner, public, created_at, updated_at from playlist; + +drop table playlist; + +alter table playlist_dg_tmp rename to playlist; + +create index playlist_name + on playlist (name); + +update playlist set song_count = (select count(*) from playlist_tracks where playlist_id = playlist.id) +where id <> '' + +`) + return err +} + +func Up20200516140647UpdatePlaylistTracks(tx *sql.Tx, id string, tracks string) error { + trackList := strings.Split(tracks, ",") + stmt, err := tx.Prepare("insert into playlist_tracks (playlist_id, media_file_id, id) values (?, ?, ?)") + if err != nil { + return err + } + for i, trackId := range trackList { + _, err := stmt.Exec(id, trackId, i+1) + if err != nil { + log.Error("Error adding track to playlist", "playlistId", id, "trackId", trackId, err) + } + } + return nil +} + +func Down20200516140647(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200608153717_referential_integrity.go b/db/migrations/20200608153717_referential_integrity.go new file mode 100644 index 0000000..2959237 --- /dev/null +++ b/db/migrations/20200608153717_referential_integrity.go @@ -0,0 +1,138 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20200608153717, Down20200608153717) +} + +func Up20200608153717(_ context.Context, tx *sql.Tx) error { + // First delete dangling players + _, 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.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.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(tx) + if err != nil { + return err + } + + // Add foreign key to playlist table + err = updatePlaylist_20200608153717(tx) + if err != nil { + return err + } + + // Add foreign keys to playlist_tracks table + return updatePlaylistTracks_20200608153717(tx) +} + +func updatePlayer_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` +create table player_dg_tmp +( + id varchar(255) not null + primary key, + name varchar not null + unique, + type varchar, + user_name varchar not null + references user (user_name) + on update cascade on delete cascade, + client varchar not null, + ip_address varchar, + last_seen timestamp, + max_bit_rate int default 0, + transcoding_id varchar null +); + +insert into player_dg_tmp(id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id) select id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id from player; + +drop table player; + +alter table player_dg_tmp rename to player; +`) + return err +} + +func updatePlaylist_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` +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, + owner varchar(255) default '' not null + constraint playlist_user_user_name_fk + references user (user_name) + on update cascade on delete cascade, + public bool default FALSE not null, + created_at datetime, + updated_at datetime +); + +insert into playlist_dg_tmp(id, name, comment, duration, song_count, owner, public, created_at, updated_at) select id, name, comment, duration, song_count, owner, public, created_at, updated_at from playlist; + +drop table playlist; + +alter table playlist_dg_tmp rename to playlist; + +create index playlist_name + on playlist (name); +`) + return err +} + +func updatePlaylistTracks_20200608153717(tx *sql.Tx) error { + _, err := tx.Exec(` +create table playlist_tracks_dg_tmp +( + id integer default 0 not null, + playlist_id varchar(255) not null + constraint playlist_tracks_playlist_id_fk + references playlist + on update cascade on delete cascade, + media_file_id varchar(255) not null +); + +insert into playlist_tracks_dg_tmp(id, playlist_id, media_file_id) select id, playlist_id, media_file_id from playlist_tracks; + +drop table playlist_tracks; + +alter table playlist_tracks_dg_tmp rename to playlist_tracks; + +create unique index playlist_tracks_pos + on playlist_tracks (playlist_id, id); + +`) + return err +} + +func Down20200608153717(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200706231659_add_default_transcodings.go b/db/migrations/20200706231659_add_default_transcodings.go new file mode 100644 index 0000000..a498d32 --- /dev/null +++ b/db/migrations/20200706231659_add_default_transcodings.go @@ -0,0 +1,43 @@ +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(upAddDefaultTranscodings, downAddDefaultTranscodings) +} + +func upAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { + row := tx.QueryRow("SELECT COUNT(*) FROM transcoding") + var count int + err := row.Scan(&count) + if err != nil { + return err + } + if count > 0 { + return nil + } + + stmt, err := tx.Prepare("insert into transcoding (id, name, target_format, default_bit_rate, command) values (?, ?, ?, ?, ?)") + if err != nil { + return err + } + + for _, t := range consts.DefaultTranscodings { + _, err := stmt.Exec(id.NewRandom(), t.Name, t.TargetFormat, t.DefaultBitRate, t.Command) + if err != nil { + return err + } + } + return nil +} + +func downAddDefaultTranscodings(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200710211442_add_playlist_path.go b/db/migrations/20200710211442_add_playlist_path.go new file mode 100644 index 0000000..8abfed6 --- /dev/null +++ b/db/migrations/20200710211442_add_playlist_path.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPlaylistPath, downAddPlaylistPath) +} + +func upAddPlaylistPath(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table playlist + add path string default '' not null; + +alter table playlist + add sync bool default false not null; +`) + + return err +} + +func downAddPlaylistPath(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200731095603_create_play_queues_table.go b/db/migrations/20200731095603_create_play_queues_table.go new file mode 100644 index 0000000..d63a1ec --- /dev/null +++ b/db/migrations/20200731095603_create_play_queues_table.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upCreatePlayQueuesTable, downCreatePlayQueuesTable) +} + +func upCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table playqueue +( + id varchar(255) not null primary key, + user_id varchar(255) not null + references user (id) + on update cascade on delete cascade, + comment varchar(255), + current varchar(255) not null, + position integer, + changed_by varchar(255), + items varchar(255), + created_at datetime, + updated_at datetime +); +`) + + return err +} + +func downCreatePlayQueuesTable(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200801101355_create_bookmark_table.go b/db/migrations/20200801101355_create_bookmark_table.go new file mode 100644 index 0000000..fe68faf --- /dev/null +++ b/db/migrations/20200801101355_create_bookmark_table.go @@ -0,0 +1,54 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upCreateBookmarkTable, downCreateBookmarkTable) +} + +func upCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table bookmark +( + user_id varchar(255) not null + references user + on update cascade on delete cascade, + item_id varchar(255) not null, + item_type varchar(255) not null, + comment varchar(255), + position integer, + changed_by varchar(255), + created_at datetime, + updated_at datetime, + constraint bookmark_pk + unique (user_id, item_id, item_type) +); + +create table playqueue_dg_tmp +( + id varchar(255) not null, + user_id varchar(255) not null + references user + on update cascade on delete cascade, + current varchar(255), + position real, + changed_by varchar(255), + items varchar(255), + created_at datetime, + updated_at datetime +); +drop table playqueue; +alter table playqueue_dg_tmp rename to playqueue; +`) + + return err +} + +func downCreateBookmarkTable(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20200819111809_drop_email_unique_constraint.go b/db/migrations/20200819111809_drop_email_unique_constraint.go new file mode 100644 index 0000000..b2dd428 --- /dev/null +++ b/db/migrations/20200819111809_drop_email_unique_constraint.go @@ -0,0 +1,43 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upDropEmailUniqueConstraint, downDropEmailUniqueConstraint) +} + +func upDropEmailUniqueConstraint(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table user_dg_tmp +( + id varchar(255) not null + primary key, + user_name varchar(255) default '' not null + unique, + name varchar(255) 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; +`) + return err +} + +func downDropEmailUniqueConstraint(_ context.Context, tx *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 new file mode 100644 index 0000000..7ee7a28 --- /dev/null +++ b/db/migrations/20201003111749_add_starred_at_index.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201003111749, Down20201003111749) +} + +func Up20201003111749(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create index if not exists annotation_starred_at + on annotation (starred_at); + `) + return err +} + +func Down20201003111749(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20201010162350_add_album_size.go b/db/migrations/20201010162350_add_album_size.go new file mode 100644 index 0000000..f1182ab --- /dev/null +++ b/db/migrations/20201010162350_add_album_size.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201010162350, Down20201010162350) +} + +func Up20201010162350(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table album + add size integer default 0 not null; +create index if not exists album_size + on album(size); + +update album set size = ifnull(( +select sum(f.size) +from media_file f +where f.album_id = album.id +), 0) +where id not null;`) + + return err +} + +func Down20201010162350(_ context.Context, 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/migrations/20201012210022_add_artist_playlist_size.go new file mode 100644 index 0000000..4eb67f1 --- /dev/null +++ b/db/migrations/20201012210022_add_artist_playlist_size.go @@ -0,0 +1,45 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201012210022, Down20201012210022) +} + +func Up20201012210022(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table artist + add size integer default 0 not null; +create index if not exists artist_size + on artist(size); + +update artist set size = ifnull(( + select sum(f.size) + from album f + where f.album_artist_id = artist.id +), 0) +where id not null; + +alter table playlist + add size integer default 0 not null; +create index if not exists playlist_size + on playlist(size); + +update playlist set size = ifnull(( + select sum(size) + from media_file f + left join playlist_tracks pt on f.id = pt.media_file_id + where pt.playlist_id = playlist.id +), 0);`) + + return err +} + +func Down20201012210022(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20201021085410_add_mbids.go b/db/migrations/20201021085410_add_mbids.go new file mode 100644 index 0000000..624bb1a --- /dev/null +++ b/db/migrations/20201021085410_add_mbids.go @@ -0,0 +1,59 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201021085410, Down20201021085410) +} + +func Up20201021085410(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add mbz_track_id varchar(255); +alter table media_file + add mbz_album_id varchar(255); +alter table media_file + add mbz_artist_id varchar(255); +alter table media_file + add mbz_album_artist_id varchar(255); +alter table media_file + add mbz_album_type varchar(255); +alter table media_file + add mbz_album_comment varchar(255); +alter table media_file + add catalog_num varchar(255); + +alter table album + add mbz_album_id varchar(255); +alter table album + add mbz_album_artist_id varchar(255); +alter table album + add mbz_album_type varchar(255); +alter table album + add mbz_album_comment varchar(255); +alter table album + add catalog_num varchar(255); + +create index if not exists album_mbz_album_type + on album (mbz_album_type); + +alter table artist + add mbz_artist_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 Down20201021085410(_ context.Context, 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/migrations/20201021093209_add_media_file_indexes.go new file mode 100644 index 0000000..f3a8009 --- /dev/null +++ b/db/migrations/20201021093209_add_media_file_indexes.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201021093209, Down20201021093209) +} + +func Up20201021093209(_ context.Context, 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 + on media_file (album_artist); +create index if not exists media_file_mbz_track_id + on media_file (mbz_track_id); +`) + return err +} + +func Down20201021093209(_ context.Context, tx *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 new file mode 100644 index 0000000..ca04d8a --- /dev/null +++ b/db/migrations/20201021135455_add_media_file_artist_index.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201021135455, Down20201021135455) +} + +func Up20201021135455(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20201030162009_add_artist_info_table.go b/db/migrations/20201030162009_add_artist_info_table.go new file mode 100644 index 0000000..f2917ae --- /dev/null +++ b/db/migrations/20201030162009_add_artist_info_table.go @@ -0,0 +1,36 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddArtistImageUrl, downAddArtistImageUrl) +} + +func upAddArtistImageUrl(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table artist + add biography varchar(255) default '' not null; +alter table artist + add small_image_url varchar(255) default '' not null; +alter table artist + add medium_image_url varchar(255) default '' not null; +alter table artist + add large_image_url varchar(255) default '' not null; +alter table artist + add similar_artists varchar(255) default '' not null; +alter table artist + add external_url varchar(255) default '' not null; +alter table artist + add external_info_updated_at datetime; +`) + return err +} + +func downAddArtistImageUrl(_ context.Context, tx *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 new file mode 100644 index 0000000..5bb17b8 --- /dev/null +++ b/db/migrations/20201110205344_add_comments_and_lyrics.go @@ -0,0 +1,33 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201110205344, Down20201110205344) +} + +func Up20201110205344(_ context.Context, 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(_ context.Context, tx *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 new file mode 100644 index 0000000..db102df --- /dev/null +++ b/db/migrations/20201128100726_add_real-path_option.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201128100726, Down20201128100726) +} + +func Up20201128100726(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table player + add report_real_path bool default FALSE not null; +`) + return err +} + +func Down20201128100726(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20201213124814_add_all_artist_ids_to_album.go b/db/migrations/20201213124814_add_all_artist_ids_to_album.go new file mode 100644 index 0000000..170497f --- /dev/null +++ b/db/migrations/20201213124814_add_all_artist_ids_to_album.go @@ -0,0 +1,64 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(Up20201213124814, Down20201213124814) +} + +func Up20201213124814(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table album + add all_artist_ids varchar; + +create index if not exists album_all_artist_ids + on album (all_artist_ids); +`) + if err != nil { + return err + } + + return updateAlbums20201213124814(tx) +} + +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 + `) + if err != nil { + return err + } + defer rows.Close() + + stmt, err := tx.Prepare("update album set all_artist_ids = ? where id = ?") + if err != nil { + return err + } + + var id, name, artistId, albumArtistId string + var songArtistIds sql.NullString + for rows.Next() { + err = rows.Scan(&id, &name, &artistId, &albumArtistId, &songArtistIds) + if err != nil { + return err + } + all := str.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) + } + } + return rows.Err() +} + +func Down20201213124814(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210322132848_add_timestamp_indexes.go b/db/migrations/20210322132848_add_timestamp_indexes.go new file mode 100644 index 0000000..3341dd3 --- /dev/null +++ b/db/migrations/20210322132848_add_timestamp_indexes.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddTimestampIndexesGo, downAddTimestampIndexesGo) +} + +func upAddTimestampIndexesGo(_ context.Context, 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 + on album (created_at); +create index if not exists playlist_updated_at + on playlist (updated_at); +create index if not exists playlist_created_at + on playlist (created_at); +create index if not exists media_file_created_at + on media_file (created_at); +create index if not exists media_file_updated_at + on media_file (updated_at); +`) + return err +} + +func downAddTimestampIndexesGo(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210418232815_fix_album_comments.go b/db/migrations/20210418232815_fix_album_comments.go new file mode 100644 index 0000000..5906764 --- /dev/null +++ b/db/migrations/20210418232815_fix_album_comments.go @@ -0,0 +1,68 @@ +package migrations + +import ( + "context" + "database/sql" + "strings" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upFixAlbumComments, downFixAlbumComments) +} + +func upFixAlbumComments(_ context.Context, tx *sql.Tx) error { + //nolint:gosec + 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 + } + defer rows.Close() + + stmt, err := tx.Prepare("UPDATE album SET comment = ? WHERE id = ?") + if err != nil { + return err + } + var id string + var comments sql.NullString + + for rows.Next() { + err = rows.Scan(&id, &comments) + if err != nil { + return err + } + if !comments.Valid { + continue + } + comment := getComment(comments.String, consts.Zwsp) + _, err = stmt.Exec(comment, id) + + if err != nil { + log.Error("Error setting album's comments", "albumId", id, err) + } + } + return rows.Err() +} + +func downFixAlbumComments(_ context.Context, tx *sql.Tx) error { + return nil +} + +func getComment(comments string, separator string) string { + cs := strings.Split(comments, separator) + if len(cs) == 0 { + return "" + } + first := cs[0] + for _, c := range cs[1:] { + if first != c { + return "" + } + } + return first +} diff --git a/db/migrations/20210430212322_add_bpm_metadata.go b/db/migrations/20210430212322_add_bpm_metadata.go new file mode 100644 index 0000000..721c9e1 --- /dev/null +++ b/db/migrations/20210430212322_add_bpm_metadata.go @@ -0,0 +1,31 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddBpmMetadata, downAddBpmMetadata) +} + +func upAddBpmMetadata(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210530121921_create_shares_table.go b/db/migrations/20210530121921_create_shares_table.go new file mode 100644 index 0000000..e9208bd --- /dev/null +++ b/db/migrations/20210530121921_create_shares_table.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upCreateSharesTable, downCreateSharesTable) +} + +func upCreateSharesTable(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table share +( + id varchar(255) not null primary key, + name varchar(255) not null unique, + description varchar(255), + expires datetime, + created datetime, + last_visited datetime, + resource_ids varchar not null, + resource_type varchar(255) not null, + visit_count integer default 0 +); +`) + + return err +} + +func downCreateSharesTable(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210601231734_update_share_fieldnames.go b/db/migrations/20210601231734_update_share_fieldnames.go new file mode 100644 index 0000000..965c018 --- /dev/null +++ b/db/migrations/20210601231734_update_share_fieldnames.go @@ -0,0 +1,26 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upUpdateShareFieldNames, downUpdateShareFieldNames) +} + +func upUpdateShareFieldNames(_ context.Context, 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; +`) + + return err +} + +func downUpdateShareFieldNames(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210616150710_encrypt_all_passwords.go b/db/migrations/20210616150710_encrypt_all_passwords.go new file mode 100644 index 0000000..f67e3fb --- /dev/null +++ b/db/migrations/20210616150710_encrypt_all_passwords.go @@ -0,0 +1,56 @@ +package migrations + +import ( + "context" + "crypto/sha256" + "database/sql" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upEncodeAllPasswords, downEncodeAllPasswords) +} + +func upEncodeAllPasswords(ctx context.Context, tx *sql.Tx) error { + rows, err := tx.Query(`SELECT id, user_name, password from user;`) + if err != nil { + return err + } + defer rows.Close() + + stmt, err := tx.Prepare("UPDATE user SET password = ? WHERE id = ?") + if err != nil { + return err + } + var id string + var username, password string + + data := sha256.Sum256([]byte(consts.DefaultEncryptionKey)) + encKey := data[0:] + + for rows.Next() { + err = rows.Scan(&id, &username, &password) + if err != nil { + return err + } + + password, err = utils.Encrypt(ctx, encKey, password) + if err != nil { + log.Error("Error encrypting user's password", "id", id, "username", username, err) + } + + _, err = stmt.Exec(password, id) + if err != nil { + log.Error("Error saving user's encrypted password", "id", id, "username", username, err) + } + } + return rows.Err() +} + +func downEncodeAllPasswords(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210619231716_drop_player_name_unique_constraint.go b/db/migrations/20210619231716_drop_player_name_unique_constraint.go new file mode 100644 index 0000000..2003321 --- /dev/null +++ b/db/migrations/20210619231716_drop_player_name_unique_constraint.go @@ -0,0 +1,48 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upDropPlayerNameUniqueConstraint, downDropPlayerNameUniqueConstraint) +} + +func upDropPlayerNameUniqueConstraint(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table player_dg_tmp +( + id varchar(255) not null + primary key, + name varchar not null, + user_agent varchar, + user_name varchar not null + references user (user_name) + on update cascade on delete cascade, + client varchar not null, + ip_address varchar, + last_seen timestamp, + max_bit_rate int default 0, + transcoding_id varchar, + report_real_path bool default FALSE not null +); + +insert into player_dg_tmp(id, name, user_agent, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id, report_real_path) select id, name, type, user_name, client, ip_address, last_seen, max_bit_rate, transcoding_id, report_real_path from player; + +drop table player; + +alter table player_dg_tmp rename to player; +create index if not exists player_match + on player (client, user_agent, user_name); +create index if not exists player_name + on player (name); +`) + return err +} + +func downDropPlayerNameUniqueConstraint(_ context.Context, tx *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 new file mode 100644 index 0000000..5257dfa --- /dev/null +++ b/db/migrations/20210623155401_add_user_prefs_player_scrobbler_enabled.go @@ -0,0 +1,45 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddUserPrefsPlayerScrobblerEnabled, downAddUserPrefsPlayerScrobblerEnabled) +} + +func upAddUserPrefsPlayerScrobblerEnabled(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210625223901_add_referential_integrity_to_user_props.go b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go new file mode 100644 index 0000000..033392d --- /dev/null +++ b/db/migrations/20210625223901_add_referential_integrity_to_user_props.go @@ -0,0 +1,39 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddReferentialIntegrityToUserProps, downAddReferentialIntegrityToUserProps) +} + +func upAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table user_props_dg_tmp +( + user_id varchar not null + constraint user_props_user_id_fk + references user + on update cascade on delete cascade, + key varchar not null, + value varchar, + constraint user_props_pk + primary key (user_id, key) +); + +insert into user_props_dg_tmp(user_id, key, value) select user_id, key, value from user_props; + +drop table user_props; + +alter table user_props_dg_tmp rename to user_props; +`) + return err +} + +func downAddReferentialIntegrityToUserProps(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210626213026_add_scrobble_buffer.go b/db/migrations/20210626213026_add_scrobble_buffer.go new file mode 100644 index 0000000..1c4d0de --- /dev/null +++ b/db/migrations/20210626213026_add_scrobble_buffer.go @@ -0,0 +1,39 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddScrobbleBuffer, downAddScrobbleBuffer) +} + +func upAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table if not exists scrobble_buffer +( + 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, + constraint scrobble_buffer_pk + unique (user_id, service, media_file_id, play_time, user_id) +); +`) + + return err +} + +func downAddScrobbleBuffer(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210715151153_add_genre_tables.go b/db/migrations/20210715151153_add_genre_tables.go new file mode 100644 index 0000000..ab2c542 --- /dev/null +++ b/db/migrations/20210715151153_add_genre_tables.go @@ -0,0 +1,69 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddGenreTables, downAddGenreTables) +} + +func upAddGenreTables(_ context.Context, 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, + name varchar not null, + constraint genre_name_ux + unique (name) +); + +create table if not exists album_genres +( + album_id varchar default null not null + references album + on delete cascade, + genre_id varchar default null not null + references genre + on delete cascade, + constraint album_genre_ux + unique (album_id, genre_id) +); + +create table if not exists media_file_genres +( + media_file_id varchar default null not null + references media_file + on delete cascade, + genre_id varchar default null not null + references genre + on delete cascade, + constraint media_file_genre_ux + unique (media_file_id, genre_id) +); + +create table if not exists artist_genres +( + artist_id varchar default null not null + references artist + on delete cascade, + genre_id varchar default null not null + references genre + on delete cascade, + constraint artist_genre_ux + unique (artist_id, genre_id) +); +`) + if err != nil { + return err + } + return forceFullRescan(tx) +} + +func downAddGenreTables(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20210821212604_add_mediafile_channels.go b/db/migrations/20210821212604_add_mediafile_channels.go new file mode 100644 index 0000000..9a0988b --- /dev/null +++ b/db/migrations/20210821212604_add_mediafile_channels.go @@ -0,0 +1,31 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMediafileChannels, downAddMediafileChannels) +} + +func upAddMediafileChannels(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20211008205505_add_smart_playlist.go b/db/migrations/20211008205505_add_smart_playlist.go new file mode 100644 index 0000000..c8ed67c --- /dev/null +++ b/db/migrations/20211008205505_add_smart_playlist.go @@ -0,0 +1,38 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddSmartPlaylist, downAddSmartPlaylist) +} + +func upAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table playlist + add column rules varchar null; +alter table playlist + add column evaluated_at datetime null; +create index if not exists playlist_evaluated_at + on playlist(evaluated_at); + +create table playlist_fields ( + field varchar(255) not null, + playlist_id varchar(255) not null + constraint playlist_fields_playlist_id_fk + references playlist + on update cascade on delete cascade +); +create unique index playlist_fields_idx + on playlist_fields (field, playlist_id); +`) + return err +} + +func downAddSmartPlaylist(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20211023184825_add_order_title_to_media_file.go b/db/migrations/20211023184825_add_order_title_to_media_file.go new file mode 100644 index 0000000..ee6fc67 --- /dev/null +++ b/db/migrations/20211023184825_add_order_title_to_media_file.go @@ -0,0 +1,62 @@ +package migrations + +import ( + "context" + "database/sql" + "strings" + + "github.com/deluan/sanitize" + "github.com/navidrome/navidrome/log" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddOrderTitleToMediaFile, downAddOrderTitleToMediaFile) +} + +func upAddOrderTitleToMediaFile(_ context.Context, 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 + on media_file (order_title); +`) + if err != nil { + return err + } + + return upAddOrderTitleToMediaFile_populateOrderTitle(tx) +} + +//goland:noinspection GoSnakeCaseUsage +func upAddOrderTitleToMediaFile_populateOrderTitle(tx *sql.Tx) error { + rows, err := tx.Query(`select id, title from media_file`) + if err != nil { + return err + } + defer rows.Close() + + stmt, err := tx.Prepare("update media_file set order_title = ? where id = ?") + if err != nil { + return err + } + + var id, title string + for rows.Next() { + err = rows.Scan(&id, &title) + if err != nil { + return err + } + + orderTitle := strings.TrimSpace(sanitize.Accents(title)) + _, err = stmt.Exec(orderTitle, id) + if err != nil { + log.Error("Error setting media_file's order_title", "title", title, "id", id, err) + } + } + return rows.Err() +} + +func downAddOrderTitleToMediaFile(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20211026191915_unescape_lyrics_and_comments.go b/db/migrations/20211026191915_unescape_lyrics_and_comments.go new file mode 100644 index 0000000..d4ba5e1 --- /dev/null +++ b/db/migrations/20211026191915_unescape_lyrics_and_comments.go @@ -0,0 +1,48 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upUnescapeLyricsAndComments, downUnescapeLyricsAndComments) +} + +func upUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { + rows, err := tx.Query(`select id, comment, lyrics, title from media_file`) + if err != nil { + return err + } + defer rows.Close() + + stmt, err := tx.Prepare("update media_file set comment = ?, lyrics = ? where id = ?") + if err != nil { + return err + } + + var id, title string + var comment, lyrics sql.NullString + for rows.Next() { + err = rows.Scan(&id, &comment, &lyrics, &title) + if err != nil { + return err + } + + newComment := str.SanitizeText(comment.String) + newLyrics := str.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) + } + } + return rows.Err() +} + +func downUnescapeLyricsAndComments(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20211029213200_add_userid_to_playlist.go b/db/migrations/20211029213200_add_userid_to_playlist.go new file mode 100644 index 0000000..e262fc2 --- /dev/null +++ b/db/migrations/20211029213200_add_userid_to_playlist.go @@ -0,0 +1,61 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddUseridToPlaylist, downAddUseridToPlaylist) +} + +func upAddUseridToPlaylist(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +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, + (select id from user where user_name = owner) as user_id from playlist; + +drop table playlist; +alter table playlist_dg_tmp rename to playlist; +create index playlist_created_at + on playlist (created_at); +create index playlist_evaluated_at + on playlist (evaluated_at); +create index playlist_name + on playlist (name); +create index playlist_size + on playlist (size); +create index playlist_updated_at + on playlist (updated_at); + +`) + return err +} + +func downAddUseridToPlaylist(_ context.Context, tx *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 new file mode 100644 index 0000000..4ab4305 --- /dev/null +++ b/db/migrations/20211102215414_add_alphabetical_by_artist_index.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlphabeticalByArtistIndex, downAddAlphabeticalByArtistIndex) +} + +func upAddAlphabeticalByArtistIndex(_ context.Context, 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(_ context.Context, tx *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 new file mode 100644 index 0000000..5e078c8 --- /dev/null +++ b/db/migrations/20211105162746_remove_invalid_artist_ids.go @@ -0,0 +1,23 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upRemoveInvalidArtistIds, downRemoveInvalidArtistIds) +} + +func upRemoveInvalidArtistIds(_ context.Context, 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(_ context.Context, tx *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 new file mode 100644 index 0000000..4817621 --- /dev/null +++ b/db/migrations/20220724231849_add_musicbrainz_release_track_id.go @@ -0,0 +1,29 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMusicbrainzReleaseTrackId, downAddMusicbrainzReleaseTrackId) +} + +func upAddMusicbrainzReleaseTrackId(_ context.Context, 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(_ 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 new file mode 100644 index 0000000..ee9c77c --- /dev/null +++ b/db/migrations/20221219112733_add_album_image_paths.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlbumImagePaths, downAddAlbumImagePaths) +} + +func upAddAlbumImagePaths(_ context.Context, 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(_ context.Context, tx *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 new file mode 100644 index 0000000..a1eaa89 --- /dev/null +++ b/db/migrations/20221219140528_remove_cover_art_id.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upRemoveCoverArtId, downRemoveCoverArtId) +} + +func upRemoveCoverArtId(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230112111457_add_album_paths.go b/db/migrations/20230112111457_add_album_paths.go new file mode 100644 index 0000000..2dfb9a7 --- /dev/null +++ b/db/migrations/20230112111457_add_album_paths.go @@ -0,0 +1,68 @@ +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" +) + +func init() { + goose.AddMigrationContext(upAddAlbumPaths, downAddAlbumPaths) +} + +func upAddAlbumPaths(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(`alter table album add paths varchar;`) + if err != nil { + return err + } + + //nolint:gosec + rows, err := tx.Query(` + select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + `) + if err != nil { + return err + } + + stmt, err := tx.Prepare("update album set paths = ? where id = ?") + if err != nil { + return err + } + + var id, filePaths string + for rows.Next() { + err = rows.Scan(&id, &filePaths) + if err != nil { + return err + } + + paths := upAddAlbumPathsDirs(filePaths) + _, err = stmt.Exec(paths, id) + if err != nil { + log.Error("Error updating album's paths", "paths", paths, "id", id, err) + } + } + return rows.Err() +} + +func upAddAlbumPathsDirs(filePaths string) string { + allPaths := strings.Split(filePaths, consts.Zwsp) + var dirs []string + for _, p := range allPaths { + dir, _ := filepath.Split(p) + dirs = append(dirs, filepath.Clean(dir)) + } + slices.Sort(dirs) + dirs = slices.Compact(dirs) + return strings.Join(dirs, string(filepath.ListSeparator)) +} + +func downAddAlbumPaths(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230114121537_touch_playlists.go b/db/migrations/20230114121537_touch_playlists.go new file mode 100644 index 0000000..0f10e27 --- /dev/null +++ b/db/migrations/20230114121537_touch_playlists.go @@ -0,0 +1,21 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upTouchPlaylists, downTouchPlaylists) +} + +func upTouchPlaylists(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(`update playlist set updated_at = datetime('now');`) + return err +} + +func downTouchPlaylists(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230115103212_create_internet_radio.go b/db/migrations/20230115103212_create_internet_radio.go new file mode 100644 index 0000000..5c014da --- /dev/null +++ b/db/migrations/20230115103212_create_internet_radio.go @@ -0,0 +1,31 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upCreateInternetRadio, downCreateInternetRadio) +} + +func upCreateInternetRadio(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +create table if not exists radio +( + id varchar(255) not null primary key, + name varchar not null unique, + stream_url varchar not null, + home_page_url varchar default '' not null, + created_at datetime, + updated_at datetime +); +`) + return err +} + +func downCreateInternetRadio(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230117155559_add_replaygain_metadata.go b/db/migrations/20230117155559_add_replaygain_metadata.go new file mode 100644 index 0000000..d6be3b3 --- /dev/null +++ b/db/migrations/20230117155559_add_replaygain_metadata.go @@ -0,0 +1,35 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddReplaygainMetadata, downAddReplaygainMetadata) +} + +func upAddReplaygainMetadata(_ context.Context, 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(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230117180400_add_album_info.go b/db/migrations/20230117180400_add_album_info.go new file mode 100644 index 0000000..5d6dd82 --- /dev/null +++ b/db/migrations/20230117180400_add_album_info.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddAlbumInfo, downAddAlbumInfo) +} + +func upAddAlbumInfo(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table album + add description varchar(255) default '' not null; +alter table album + add small_image_url varchar(255) default '' not null; +alter table album + add medium_image_url varchar(255) default '' not null; +alter table album + add large_image_url varchar(255) default '' not null; +alter table album + add external_url varchar(255) default '' not null; +alter table album + add external_info_updated_at datetime; +`) + return err +} + +func downAddAlbumInfo(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230119152657_recreate_share_table.go b/db/migrations/20230119152657_recreate_share_table.go new file mode 100644 index 0000000..e1ae816 --- /dev/null +++ b/db/migrations/20230119152657_recreate_share_table.go @@ -0,0 +1,42 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddMissingShareInfo, downAddMissingShareInfo) +} + +func upAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +drop table if exists share; +create table share +( + id varchar(255) not null + primary key, + description varchar(255), + expires_at datetime, + last_visited_at datetime, + resource_ids varchar not null, + resource_type varchar(255) not null, + contents varchar, + format varchar, + max_bit_rate integer, + visit_count integer default 0, + created_at datetime, + updated_at datetime, + user_id varchar(255) not null + constraint share_user_id_fk + references user +); +`) + return err +} + +func downAddMissingShareInfo(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230202143713_change_path_list_separator.go b/db/migrations/20230202143713_change_path_list_separator.go new file mode 100644 index 0000000..78b030a --- /dev/null +++ b/db/migrations/20230202143713_change_path_list_separator.go @@ -0,0 +1,63 @@ +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" +) + +func init() { + goose.AddMigrationContext(upChangePathListSeparator, downChangePathListSeparator) +} + +func upChangePathListSeparator(_ context.Context, tx *sql.Tx) error { + //nolint:gosec + rows, err := tx.Query(` + select album_id, group_concat(path, '` + consts.Zwsp + `') from media_file group by album_id + `) + if err != nil { + return err + } + + stmt, err := tx.Prepare("update album set paths = ? where id = ?") + if err != nil { + return err + } + + var id, filePaths string + for rows.Next() { + err = rows.Scan(&id, &filePaths) + if err != nil { + return err + } + + paths := upChangePathListSeparatorDirs(filePaths) + _, err = stmt.Exec(paths, id) + if err != nil { + log.Error("Error updating album's paths", "paths", paths, "id", id, err) + } + } + return rows.Err() +} + +func upChangePathListSeparatorDirs(filePaths string) string { + allPaths := strings.Split(filePaths, consts.Zwsp) + var dirs []string + for _, p := range allPaths { + dir, _ := filepath.Split(p) + dirs = append(dirs, filepath.Clean(dir)) + } + slices.Sort(dirs) + dirs = slices.Compact(dirs) + return strings.Join(dirs, consts.Zwsp) +} + +func downChangePathListSeparator(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230209181414_change_image_files_list_separator.go b/db/migrations/20230209181414_change_image_files_list_separator.go new file mode 100644 index 0000000..7f4d4cb --- /dev/null +++ b/db/migrations/20230209181414_change_image_files_list_separator.go @@ -0,0 +1,60 @@ +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" +) + +func init() { + goose.AddMigrationContext(upChangeImageFilesListSeparator, downChangeImageFilesListSeparator) +} + +func upChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { + rows, err := tx.Query(`select id, image_files from album`) + if err != nil { + return err + } + + stmt, err := tx.Prepare("update album set image_files = ? where id = ?") + if err != nil { + return err + } + + var id string + var imageFiles sql.NullString + for rows.Next() { + err = rows.Scan(&id, &imageFiles) + if err != nil { + return err + } + + files := upChangeImageFilesListSeparatorDirs(imageFiles.String) + if files == imageFiles.String { + continue + } + _, err = stmt.Exec(files, id) + if err != nil { + log.Error("Error updating album's image file list", "files", files, "id", id, err) + } + } + return rows.Err() +} + +func upChangeImageFilesListSeparatorDirs(filePaths string) string { + allPaths := filepath.SplitList(filePaths) + slices.Sort(allPaths) + allPaths = slices.Compact(allPaths) + return strings.Join(allPaths, consts.Zwsp) +} + +func downChangeImageFilesListSeparator(_ context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/db/migrations/20230310222612_add_download_to_share.go b/db/migrations/20230310222612_add_download_to_share.go new file mode 100644 index 0000000..ed2879e --- /dev/null +++ b/db/migrations/20230310222612_add_download_to_share.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddDownloadToShare, downAddDownloadToShare) +} + +func upAddDownloadToShare(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table share + add downloadable bool not null default false; +`) + return err +} + +func downAddDownloadToShare(_ context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20230404104309_empty_sql_migration.sql b/db/migrations/20230404104309_empty_sql_migration.sql new file mode 100644 index 0000000..7a2c921 --- /dev/null +++ b/db/migrations/20230404104309_empty_sql_migration.sql @@ -0,0 +1,13 @@ +-- This file has intentionally no SQL logic. It is here to avoid an error in the linter: +-- db/db.go:23:4: invalid go:embed: build system did not supply embed configuration (typecheck) +-- + +-- +goose Up +-- +goose StatementBegin +SELECT 'up SQL query'; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +SELECT 'down SQL query'; +-- +goose StatementEnd diff --git a/db/migrations/20230515184510_add_release_date.go b/db/migrations/20230515184510_add_release_date.go new file mode 100644 index 0000000..1141a1e --- /dev/null +++ b/db/migrations/20230515184510_add_release_date.go @@ -0,0 +1,50 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddRelRecYear, downAddRelRecYear) +} + +func upAddRelRecYear(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + add date varchar(255) default '' not null; +alter table media_file + add original_year int default 0 not null; +alter table media_file + add original_date varchar(255) default '' not null; +alter table media_file + add release_year int default 0 not null; +alter table media_file + add release_date varchar(255) default '' not null; + +alter table album + add date varchar(255) default '' not null; +alter table album + add min_original_year int default 0 not null; +alter table album + add max_original_year int default 0 not null; +alter table album + add original_date varchar(255) default '' not null; +alter table album + add release_date varchar(255) default '' not null; +alter table album + add releases integer default 0 not null; +`) + if err != nil { + return err + } + + notice(tx, "A full rescan needs to be performed to import more tags") + return forceFullRescan(tx) +} + +func downAddRelRecYear(_ context.Context, tx *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 new file mode 100644 index 0000000..170fc26 --- /dev/null +++ b/db/migrations/20230616214944_rename_musicbrainz_recording_id.go @@ -0,0 +1,28 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upRenameMusicbrainzRecordingId, downRenameMusicbrainzRecordingId) +} + +func upRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +alter table media_file + rename column mbz_track_id to mbz_recording_id; +`) + return err +} + +func downRenameMusicbrainzRecordingId(_ context.Context, tx *sql.Tx) error { + _, err := tx.Exec(` +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 new file mode 100644 index 0000000..e4bf9a7 --- /dev/null +++ b/db/migrations/20231208182311_add_discs_to_album.go @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..ac73fc9 --- /dev/null +++ b/db/migrations/20231209211223_alter_lyric_column.go @@ -0,0 +1,82 @@ +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.Query(`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 + } + + lyrics, err := model.ToLyrics("xxx", lyrics.String) + if err != nil { + return err + } + + text, err := json.Marshal(model.LyricList{*lyrics}) + 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(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 new file mode 100644 index 0000000..a65b0ae --- /dev/null +++ b/db/migrations/20240122223340_add_default_values_to_null_columns.go.go @@ -0,0 +1,563 @@ +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 new file mode 100644 index 0000000..e3df635 --- /dev/null +++ b/db/migrations/20240426202913_add_id_to_scrobble_buffer.go @@ -0,0 +1,30 @@ +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 new file mode 100644 index 0000000..619cdcf --- /dev/null +++ b/db/migrations/20240511210036_add_sample_rate.go @@ -0,0 +1,29 @@ +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(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 new file mode 100644 index 0000000..55b521c --- /dev/null +++ b/db/migrations/20240511220020_add_library_table.go @@ -0,0 +1,71 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "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, fmt.Sprintf(` + insert into library(id, name, path) values(1, 'Music Library', '%s'); + 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 new file mode 100644 index 0000000..b450b26 --- /dev/null +++ b/db/migrations/20240629152843_remove_annotation_id.go @@ -0,0 +1,66 @@ +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(ctx context.Context, tx *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 new file mode 100644 index 0000000..6532f0f --- /dev/null +++ b/db/migrations/20240802044339_player_use_user_id_over_username.go @@ -0,0 +1,62 @@ +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 new file mode 100644 index 0000000..6d86971 --- /dev/null +++ b/db/migrations/20241020003138_add_sort_tags_index.sql @@ -0,0 +1,9 @@ +-- +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 new file mode 100644 index 0000000..d2edde0 --- /dev/null +++ b/db/migrations/20241024125533_fix_sort_tags_collation_and_index.sql @@ -0,0 +1,512 @@ +-- +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 new file mode 100644 index 0000000..fcbef7e --- /dev/null +++ b/db/migrations/20241026183640_support_new_scanner.go @@ -0,0 +1,319 @@ +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(tx, "A full scan will be triggered to populate the new tables. This may take a while.") + return forceFullRescan(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 new file mode 100644 index 0000000..91ff653 --- /dev/null +++ b/db/migrations/20250522013904_share_user_id_on_delete.sql @@ -0,0 +1,36 @@ +-- +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 new file mode 100644 index 0000000..d9250eb --- /dev/null +++ b/db/migrations/20250611010101_playqueue_current_to_index.go @@ -0,0 +1,80 @@ +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(ctx context.Context, tx *sql.Tx) error { + return nil +} diff --git a/db/migrations/20250701010101_add_folder_hash.go b/db/migrations/20250701010101_add_folder_hash.go new file mode 100644 index 0000000..e82a074 --- /dev/null +++ b/db/migrations/20250701010101_add_folder_hash.go @@ -0,0 +1,21 @@ +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(ctx context.Context, tx *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 new file mode 100644 index 0000000..114de2a --- /dev/null +++ b/db/migrations/20250701010102_add_annotation_user_foreign_key.sql @@ -0,0 +1,46 @@ +-- +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 new file mode 100644 index 0000000..8025229 --- /dev/null +++ b/db/migrations/20250701010103_add_library_stats.go @@ -0,0 +1,48 @@ +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(ctx context.Context, tx *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 new file mode 100644 index 0000000..163608d --- /dev/null +++ b/db/migrations/20250701010104_make_replaygain_fields_nullable.go @@ -0,0 +1,49 @@ +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(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 new file mode 100644 index 0000000..aede49b --- /dev/null +++ b/db/migrations/20250701010105_remove_dangling_items.sql @@ -0,0 +1,7 @@ +-- +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 new file mode 100644 index 0000000..1cd67dc --- /dev/null +++ b/db/migrations/20250701010106_add_participant_stats_to_all_artists.sql @@ -0,0 +1,65 @@ +-- +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 new file mode 100644 index 0000000..f8a5a44 --- /dev/null +++ b/db/migrations/20250701010107_add_mbid_indexes.sql @@ -0,0 +1,27 @@ +-- +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 new file mode 100644 index 0000000..654784d --- /dev/null +++ b/db/migrations/20250701010108_add_multi_library_support.go @@ -0,0 +1,119 @@ +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 new file mode 100644 index 0000000..de20f0c --- /dev/null +++ b/db/migrations/20250823142158_make_playqueue_position_int.sql @@ -0,0 +1,9 @@ +-- +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 new file mode 100644 index 0000000..9dac46a --- /dev/null +++ b/db/migrations/20251109010105_add_annotation_rating_date.sql @@ -0,0 +1,7 @@ +-- +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 new file mode 100644 index 0000000..9791c48 --- /dev/null +++ b/db/migrations/20251206013022_create_scrobbles_table.sql @@ -0,0 +1,20 @@ +-- +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/migration.go b/db/migrations/migration.go new file mode 100644 index 0000000..fde6f58 --- /dev/null +++ b/db/migrations/migration.go @@ -0,0 +1,123 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + "strings" + "sync" + + "github.com/navidrome/navidrome/conf" + "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) { + 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(tx *sql.Tx) error { + // If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`. + if conf.Server.DevOptimizeDB { + _, err := tx.Exec(`ANALYZE;`) + if err != nil { + return err + } + } + _, err := tx.Exec(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(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) + } +} + +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/git/pre-commit b/git/pre-commit new file mode 100755 index 0000000..39ec879 --- /dev/null +++ b/git/pre-commit @@ -0,0 +1,28 @@ +#!/bin/sh +# Copyright 2012 The Go Authors. All rights reserved. +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file. + +# git gofmt pre-commit hook +# +# To use, store as .git/hooks/pre-commit inside your repository and make sure +# it has execute permissions. +# +# This script does not handle file names that contain spaces. + +gofmtcmd="go tool goimports" + +gofiles=$(git diff --cached --name-only --diff-filter=ACM | grep '.go$' | grep -v '_gen.go$' | grep -v '.pb.go$') +[ -z "$gofiles" ] && exit 0 + +unformatted=$($gofmtcmd -l $gofiles) +[ -z "$unformatted" ] && exit 0 + +# Some files are not gofmt'd. Print message and fail. + +echo >&2 "Go files must be formatted with '$gofmtcmd'. Please run:" +for fn in $unformatted; do + echo >&2 " $gofmtcmd -w $PWD/$fn" +done + +exit 1 diff --git a/git/pre-push b/git/pre-push new file mode 100755 index 0000000..da7eaba --- /dev/null +++ b/git/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +set -e + +make pre-push \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5921890 --- /dev/null +++ b/go.mod @@ -0,0 +1,149 @@ +module github.com/navidrome/navidrome + +go 1.25 + +// Fork to fix https://github.com/navidrome/navidrome/issues/3254 +replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d + +require ( + github.com/Masterminds/squirrel v1.5.4 + github.com/RaveNoX/go-jsoncommentstrip v1.0.0 + github.com/andybalholm/cascadia v1.3.3 + github.com/bmatcuk/doublestar/v4 v4.9.1 + github.com/bradleyjkemp/cupaloy/v2 v2.8.0 + github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf + github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 + github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 + github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 + github.com/disintegration/imaging v1.6.2 + github.com/djherbis/atime v1.1.0 + github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4 + github.com/djherbis/stream v1.4.0 + github.com/djherbis/times v1.6.0 + github.com/dustin/go-humanize v1.0.1 + github.com/fatih/structs v1.1.0 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/cors v1.2.2 + github.com/go-chi/httprate v0.15.0 + github.com/go-chi/jwtauth/v5 v5.3.3 + github.com/go-viper/encoding/ini v0.1.1 + github.com/gohugoio/hashstructure v0.6.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/hashicorp/go-multierror v1.1.1 + github.com/jellydator/ttlcache/v3 v3.4.0 + github.com/kardianos/service v1.2.4 + github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 + github.com/knqyf263/go-plugin v0.9.0 + github.com/kr/pretty v0.3.1 + github.com/lestrrat-go/jwx/v2 v2.1.6 + github.com/maruel/natural v1.2.1 + github.com/matoous/go-nanoid/v2 v2.1.0 + github.com/mattn/go-sqlite3 v1.14.32 + github.com/microcosm-cc/bluemonday v1.0.27 + github.com/mileusna/useragent v1.3.5 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/pocketbase/dbx v1.11.0 + github.com/pressly/goose/v3 v3.26.0 + github.com/prometheus/client_golang v1.23.2 + github.com/rjeczalik/notify v0.9.3 + github.com/robfig/cron/v3 v3.0.1 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.10.1 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + github.com/tetratelabs/wazero v1.10.1 + github.com/unrolled/secure v1.17.0 + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 + go.uber.org/goleak v1.3.0 + golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 + golang.org/x/image v0.33.0 + golang.org/x/net v0.47.0 + golang.org/x/sync v0.18.0 + golang.org/x/sys v0.38.0 + golang.org/x/term v0.37.0 + golang.org/x/text v0.31.0 + golang.org/x/time v0.14.0 + google.golang.org/protobuf v1.36.10 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + dario.cat/mergo v1.0.2 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/atombender/go-jsonschema v0.20.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/reflex v0.3.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/creack/pty v1.1.11 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // indirect + github.com/google/subcommands v1.2.0 // indirect + github.com/gorilla/css v1.0.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/text v0.2.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/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/meilisearch/meilisearch-go v0.34.2 // 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/ogier/pflag v0.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/sagikazarmark/locafero v0.12.0 // indirect + github.com/sanity-io/litter v1.5.8 // indirect + github.com/segmentio/asm v1.2.1 // indirect + github.com/sethvargo/go-retry v0.3.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.2 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect + golang.org/x/tools v0.39.0 // indirect + gopkg.in/ini.v1 v1.67.0 // 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7295df3 --- /dev/null +++ b/go.sum @@ -0,0 +1,419 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/atombender/go-jsonschema v0.20.0 h1:AHg0LeI0HcjQ686ALwUNqVJjNRcSXpIR6U+wC2J0aFY= +github.com/atombender/go-jsonschema v0.20.0/go.mod h1:ZmbuR11v2+cMM0PdP6ySxtyZEGFBmhgF4xa4J6Hdls8= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= +github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= +github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= +github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= +github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= +github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= +github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55/go.mod h1:ZNCLJfehvEf34B7BbLKjgpsL9lyW7q938w/GY1XgV4E= +github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d h1:x/R3+oPEjnisl1zBx2f2v7Gf6f11l0N0JoD6BkwcJyA= +github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d/go.mod h1:apkPC/CR3s48O2D7Y++n1XWEpgPNNCjXYga3PPbJe2E= +github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 h1:r4hxcT6GBIA/j8Ox4OXI5MNgMKfR+9plcAWYi1OnmOg= +github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933/go.mod h1:RkQWLNITKkXHLP7LXxZSgEq+uFWU25M5qW7qfEhL9Wc= +github.com/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.20231127215153-442a07e326c4 h1:wdZllsLrDJtYfHiAKogB4PNHSDeO+v+5S3eqSWHGDlc= +github.com/djherbis/fscache v0.10.2-0.20231127215153-442a07e326c4/go.mod h1:dHWjlanKIxaHVH1xJOTb4kzP800XdcXlgJ6JYlR2DPU= +github.com/djherbis/stream v1.4.0 h1:aVD46WZUiq5kJk55yxJAyw6Kuera6kmC3i2vEQyW/AE= +github.com/djherbis/stream v1.4.0/go.mod h1:cqjC1ZRq3FFwkGmUtHwcldbnW8f0Q4YuVsGW1eAFtOk= +github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= +github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= +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/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.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/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.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= +github.com/go-chi/jwtauth/v5 v5.3.3 h1:50Uzmacu35/ZP9ER2Ht6SazwPsnLQ9LRJy6zTZJpHEo= +github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/QY5vUz7aQ= +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-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +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.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +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/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE= +github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U= +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/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.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= +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/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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +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/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/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk= +github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= +github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/knqyf263/go-plugin v0.9.0 h1:CQs2+lOPIlkZVtcb835ZYDEoyyWJWLbSTWeCs0EwTwI= +github.com/knqyf263/go-plugin v0.9.0/go.mod h1:2z5lCO1/pez6qGo8CvCxSlBFSEat4MEp1DrnA+f7w8Q= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +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/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/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 v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/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.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +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/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o= +github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= +github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= +github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/meilisearch/meilisearch-go v0.34.2 h1:/OVQ2NQU3nRT5M/bhtg6pzxckxxGLy1hZyo3zjrja28= +github.com/meilisearch/meilisearch-go v0.34.2/go.mod h1:cUVJZ2zMqTvvwIMEEAdsWH+zrHsrLpAw6gm8Lt1MXK0= +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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= +github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= +github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +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/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/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/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.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= +github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +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/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.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 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.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8= +github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= +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/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/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +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.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= +golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ= +golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-20220615213510-4f61da869c0c/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/log/formatters.go b/log/formatters.go new file mode 100644 index 0000000..0b27f3a --- /dev/null +++ b/log/formatters.go @@ -0,0 +1,74 @@ +package log + +import ( + "fmt" + "io" + "iter" + "reflect" + "slices" + "strings" + "time" + + "github.com/navidrome/navidrome/utils/slice" +) + +func ShortDur(d time.Duration) string { + var s string + switch { + case d > time.Hour: + s = d.Round(time.Minute).String() + case d > time.Minute: + s = d.Round(time.Second).String() + case d > time.Second: + s = d.Round(10 * time.Millisecond).String() + case d > time.Millisecond: + s = d.Round(100 * time.Microsecond).String() + default: + s = d.String() + } + s = strings.TrimSuffix(s, "0s") + return strings.TrimSuffix(s, "0m") +} + +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 new file mode 100644 index 0000000..6ed43a0 --- /dev/null +++ b/log/formatters_test.go @@ -0,0 +1,70 @@ +package log_test + +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"), + 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")) + }) + 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")) + }) + }) +}) diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..24f3dff --- /dev/null +++ b/log/log.go @@ -0,0 +1,351 @@ +package log + +import ( + "context" + "errors" + "fmt" + "io" + "iter" + "net/http" + "os" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +type Level uint32 + +type LevelFunc = func(ctx interface{}, msg interface{}, keyValuePairs ...interface{}) + +var redacted = &Hook{ + AcceptedLevels: logrus.AllLevels, + RedactionList: []string{ + // Keys from the config + "(ApiKey:\")[\\w]*", + "(Secret:\")[\\w]*", + "(Spotify.*ID:\")[\\w]*", + "(PasswordEncryptionKey:[\\s]*\")[^\"]*", + "(UserHeader:[\\s]*\")[^\"]*", + "(TrustedSources:[\\s]*\")[^\"]*", + "(MetricsPath:[\\s]*\")[^\"]*", + "(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*", + "(DevAutoLoginUsername:[\\s]*\")[^\"]*", + + // UI appConfig + "(subsonicToken:)[\\w]+(\\s)", + "(subsonicSalt:)[\\w]+(\\s)", + "(token:)[^\\s]+", + + // Subsonic query params + "([^\\w]t=)[\\w]+", + "([^\\w]s=)[^&]+", + "([^\\w]p=)[^&]+", + "([^\\w]jwt=)[^&]+", + + // External services query params + "([^\\w]api_key=)[\\w]+", + }, +} + +const ( + LevelFatal = Level(logrus.FatalLevel) + LevelError = Level(logrus.ErrorLevel) + LevelWarn = Level(logrus.WarnLevel) + LevelInfo = Level(logrus.InfoLevel) + LevelDebug = Level(logrus.DebugLevel) + LevelTrace = Level(logrus.TraceLevel) +) + +type contextKey string + +const loggerCtxKey = contextKey("logger") + +type levelPath struct { + path string + level Level +} + +var ( + currentLevel Level + loggerMu sync.RWMutex + defaultLogger = logrus.New() + logSourceLine = false + rootPath string + logLevels []levelPath +) + +// SetLevel sets the global log level used by the simple logger. +func SetLevel(l Level) { + loggerMu.Lock() + currentLevel = l + defaultLogger.Level = logrus.TraceLevel + loggerMu.Unlock() + logrus.SetLevel(logrus.Level(l)) +} + +func SetLevelString(l string) { + level := levelFromString(l) + SetLevel(level) +} + +func levelFromString(l string) Level { + envLevel := strings.ToLower(l) + var level Level + switch envLevel { + case "fatal": + level = LevelFatal + case "error": + level = LevelError + case "warn": + level = LevelWarn + case "debug": + level = LevelDebug + case "trace": + level = LevelTrace + default: + level = LevelInfo + } + 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: levelFromString(v)}) + } + sort.Slice(logLevels, func(i, j int) bool { + return logLevels[i].path > logLevels[j].path + }) +} + +func SetLogSourceLine(enabled bool) { + logSourceLine = enabled +} + +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) +} + +// Redact applies redaction to a single string +func Redact(msg string) string { + r, _ := redacted.redact(msg) + return r +} + +func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Context { + if ctx == nil { + ctx = context.Background() + } + + logger, ok := ctx.Value(loggerCtxKey).(*logrus.Entry) + if !ok { + logger = createNewLogger() + } + logger = addFields(logger, keyValuePairs) + ctx = context.WithValue(ctx, loggerCtxKey, logger) + + return ctx +} + +func SetDefaultLogger(l *logrus.Logger) { + loggerMu.Lock() + defer loggerMu.Unlock() + defaultLogger = l +} + +func CurrentLevel() Level { + loggerMu.RLock() + defer loggerMu.RUnlock() + 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 ...interface{}) { + log(LevelFatal, args...) + os.Exit(1) +} + +func Error(args ...interface{}) { + log(LevelError, args...) +} + +func Warn(args ...interface{}) { + log(LevelWarn, args...) +} + +func Info(args ...interface{}) { + log(LevelInfo, args...) +} + +func Debug(args ...interface{}) { + log(LevelDebug, args...) +} + +func Trace(args ...interface{}) { + log(LevelTrace, args...) +} + +func log(level Level, args ...interface{}) { + if !shouldLog(level, 3) { + 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 { + loggerMu.RLock() + level := currentLevel + levels := logLevels + loggerMu.RUnlock() + + if level >= requiredLevel { + return true + } + if len(levels) == 0 { + return false + } + + _, file, _, ok := runtime.Caller(skip) + if !ok { + return false + } + + file = strings.TrimPrefix(file, rootPath) + for _, lp := range levels { + if strings.HasPrefix(file, lp.path) { + return lp.level >= requiredLevel + } + } + return false +} + +func parseArgs(args []interface{}) (*logrus.Entry, string) { + var l *logrus.Entry + var err error + if args[0] == nil { + l = createNewLogger() + args = args[1:] + } else { + l, err = extractLogger(args[0]) + if err != nil { + l = createNewLogger() + } else { + args = args[1:] + } + } + if len(args) > 1 { + kvPairs := args[1:] + l = addFields(l, kvPairs) + } + if logSourceLine { + _, file, line, ok := runtime.Caller(3) + if !ok { + file = "???" + line = 0 + } + //_, filename := path.Split(file) + //l = l.WithField("filename", filename).WithField("line", line) + l = l.WithField(" source", fmt.Sprintf("file://%s:%d", file, line)) + } + + switch msg := args[0].(type) { + case error: + return l, msg.Error() + case string: + return l, msg + } + + return l, "" +} + +func addFields(logger *logrus.Entry, keyValuePairs []interface{}) *logrus.Entry { + for i := 0; i < len(keyValuePairs); i += 2 { + switch name := keyValuePairs[i].(type) { + case error: + logger = logger.WithField("error", name.Error()) + case string: + if i+1 >= len(keyValuePairs) { + logger = logger.WithField(name, "!!!!Invalid number of arguments in log call!!!!") + } else { + switch v := keyValuePairs[i+1].(type) { + 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)) + default: + logger = logger.WithField(name, v) + } + } + } + } + return logger +} + +func extractLogger(ctx interface{}) (*logrus.Entry, error) { + switch ctx := ctx.(type) { + case *logrus.Entry: + return ctx, nil + case context.Context: + logger := ctx.Value(loggerCtxKey) + if logger != nil { + return logger.(*logrus.Entry), nil + } + return extractLogger(NewContext(ctx)) + case *http.Request: + return extractLogger(ctx.Context()) + } + return nil, errors.New("no logger found") +} + +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 +} + +func init() { + defaultLogger.Level = logrus.TraceLevel + _, file, _, ok := runtime.Caller(0) + if !ok { + return + } + rootPath = strings.TrimSuffix(file, "log/log.go") +} diff --git a/log/log_test.go b/log/log_test.go new file mode 100644 index 0000000..a1f3b6b --- /dev/null +++ b/log/log_test.go @@ -0,0 +1,249 @@ +package log + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" +) + +func TestLog(t *testing.T) { + SetLevel(LevelInfo) + RegisterFailHandler(Fail) + RunSpecs(t, "Log Suite") +} + +var _ = Describe("Logger", func() { + var l *logrus.Logger + var hook *test.Hook + + BeforeEach(func() { + l, hook = test.NewNullLogger() + SetLevel(LevelTrace) + SetDefaultLogger(l) + }) + + Describe("Logging", func() { + It("logs a simple message", func() { + Error("Simple Message") + Expect(hook.LastEntry().Message).To(Equal("Simple Message")) + Expect(hook.LastEntry().Data).To(BeEmpty()) + }) + + It("logs a message when context is nil", func() { + Error(nil, "Simple Message") + Expect(hook.LastEntry().Message).To(Equal("Simple Message")) + Expect(hook.LastEntry().Data).To(BeEmpty()) + }) + + It("Empty context", func() { + Error(context.TODO(), "Simple Message") + Expect(hook.LastEntry().Message).To(Equal("Simple Message")) + Expect(hook.LastEntry().Data).To(BeEmpty()) + }) + + It("logs messages with two kv pairs", func() { + Error("Simple Message", "key1", "value1", "key2", "value2") + Expect(hook.LastEntry().Message).To(Equal("Simple Message")) + Expect(hook.LastEntry().Data["key1"]).To(Equal("value1")) + Expect(hook.LastEntry().Data["key2"]).To(Equal("value2")) + Expect(hook.LastEntry().Data).To(HaveLen(2)) + }) + + It("logs error objects as simple messages", func() { + Error(errors.New("error test")) + Expect(hook.LastEntry().Message).To(Equal("error test")) + Expect(hook.LastEntry().Data).To(BeEmpty()) + }) + + It("logs errors passed as last argument", func() { + Error("Error scrobbling track", "id", 1, errors.New("some issue")) + Expect(hook.LastEntry().Message).To(Equal("Error scrobbling track")) + Expect(hook.LastEntry().Data["id"]).To(Equal(1)) + Expect(hook.LastEntry().Data["error"]).To(Equal("some issue")) + Expect(hook.LastEntry().Data).To(HaveLen(2)) + }) + + It("can get data from the request's context", func() { + ctx := NewContext(context.TODO(), "foo", "bar") + req := httptest.NewRequest("get", "/", nil).WithContext(ctx) + + Error(req, "Simple Message", "key1", "value1") + + Expect(hook.LastEntry().Message).To(Equal("Simple Message")) + Expect(hook.LastEntry().Data["foo"]).To(Equal("bar")) + Expect(hook.LastEntry().Data["key1"]).To(Equal("value1")) + Expect(hook.LastEntry().Data).To(HaveLen(2)) + }) + + It("does not log anything if level is lower", func() { + SetLevel(LevelError) + Info("Simple Message") + Expect(hook.LastEntry()).To(BeNil()) + }) + + It("logs source file and line number, if requested", func() { + SetLogSourceLine(true) + Error("A crash happened") + // NOTE: This assertion breaks if the line number above changes + Expect(hook.LastEntry().Data[" source"]).To(ContainSubstring("/log/log_test.go:93")) + Expect(hook.LastEntry().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() { + BeforeEach(func() { + SetLevel(LevelTrace) + }) + It("logs error messages", func() { + Error("msg") + Expect(hook.LastEntry().Level).To(Equal(logrus.ErrorLevel)) + }) + It("logs warn messages", func() { + Warn("msg") + Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) + }) + It("logs info messages", func() { + Info("msg") + Expect(hook.LastEntry().Level).To(Equal(logrus.InfoLevel)) + }) + It("logs debug messages", func() { + Debug("msg") + Expect(hook.LastEntry().Level).To(Equal(logrus.DebugLevel)) + }) + It("logs trace messages", func() { + Trace("msg") + Expect(hook.LastEntry().Level).To(Equal(logrus.TraceLevel)) + }) + }) + + Describe("LogLevels", func() { + It("logs at specific levels", func() { + SetLevel(LevelError) + Debug("message 1") + Expect(hook.LastEntry()).To(BeNil()) + + SetLogLevels(map[string]string{ + "log/log_test": "debug", + }) + + Debug("message 2") + Expect(hook.LastEntry().Message).To(Equal("message 2")) + }) + }) + + 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()) + }) + }) + + Describe("extractLogger", func() { + It("returns an error if the context is nil", func() { + _, err := extractLogger(nil) + Expect(err).ToNot(BeNil()) + }) + + It("returns an error if the context is a string", func() { + _, err := extractLogger("any msg") + Expect(err).ToNot(BeNil()) + }) + + It("returns the logger from context if it has one", func() { + logger := logrus.NewEntry(logrus.New()) + ctx := context.Background() + ctx = context.WithValue(ctx, loggerCtxKey, logger) + + Expect(extractLogger(ctx)).To(Equal(logger)) + }) + + It("returns the logger from request's context if it has one", func() { + logger := logrus.NewEntry(logrus.New()) + ctx := context.Background() + ctx = context.WithValue(ctx, loggerCtxKey, logger) + req := httptest.NewRequest("get", "/", nil).WithContext(ctx) + + Expect(extractLogger(req)).To(Equal(logger)) + }) + }) + + Describe("SetLevelString", func() { + It("converts Fatal level", func() { + SetLevelString("Fatal") + Expect(CurrentLevel()).To(Equal(LevelFatal)) + }) + It("converts Error level", func() { + SetLevelString("ERROR") + Expect(CurrentLevel()).To(Equal(LevelError)) + }) + It("converts Warn level", func() { + SetLevelString("warn") + Expect(CurrentLevel()).To(Equal(LevelWarn)) + }) + It("converts Info level", func() { + SetLevelString("info") + Expect(CurrentLevel()).To(Equal(LevelInfo)) + }) + It("converts Debug level", func() { + SetLevelString("debug") + Expect(CurrentLevel()).To(Equal(LevelDebug)) + }) + It("converts Trace level", func() { + SetLevelString("trace") + Expect(CurrentLevel()).To(Equal(LevelTrace)) + }) + }) + + Describe("Redact", func() { + Describe("Subsonic API password", 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")) + }) + }) +}) diff --git a/log/redactrus.go b/log/redactrus.go new file mode 100755 index 0000000..6e17243 --- /dev/null +++ b/log/redactrus.go @@ -0,0 +1,88 @@ +package log + +// Copied from https://github.com/whuang8/redactrus (MIT License) +// Copyright (c) 2018 William Huang + +import ( + "fmt" + "reflect" + "regexp" + + "github.com/sirupsen/logrus" +) + +// Hook is a logrus hook for redacting information from logs +type Hook struct { + // Messages with a log level not contained in this array + // will not be dispatched. If empty, all messages will be dispatched. + AcceptedLevels []logrus.Level + RedactionList []string + redactionKeys []*regexp.Regexp +} + +// Levels returns the user defined AcceptedLevels +// If AcceptedLevels is empty, all logrus levels are returned +func (h *Hook) Levels() []logrus.Level { + if len(h.AcceptedLevels) == 0 { + return logrus.AllLevels + } + return h.AcceptedLevels +} + +// Fire redacts values in a log Entry that match +// with keys defined in the RedactionList +func (h *Hook) Fire(e *logrus.Entry) error { + if err := h.initRedaction(); err != nil { + return err + } + for _, re := range h.redactionKeys { + // Redact based on key matching in Data fields + for k, v := range e.Data { + if re.MatchString(k) { + e.Data[k] = "[REDACTED]" + continue + } + if v == nil { + continue + } + switch reflect.TypeOf(v).Kind() { + case reflect.String: + e.Data[k] = re.ReplaceAllString(v.(string), "$1[REDACTED]$2") + continue + case reflect.Map: + s := fmt.Sprintf("%+v", v) + e.Data[k] = re.ReplaceAllString(s, "$1[REDACTED]$2") + continue + } + } + + // Redact based on text matching in the Message field + e.Message = re.ReplaceAllString(e.Message, "$1[REDACTED]$2") + } + + return nil +} + +func (h *Hook) initRedaction() error { + if len(h.redactionKeys) == 0 { + for _, redactionKey := range h.RedactionList { + re, err := regexp.Compile(redactionKey) + if err != nil { + return err + } + h.redactionKeys = append(h.redactionKeys, re) + } + } + return nil +} + +func (h *Hook) redact(msg string) (string, error) { + if err := h.initRedaction(); err != nil { + return msg, err + } + for _, re := range h.redactionKeys { + msg = re.ReplaceAllString(msg, "$1[REDACTED]$2") + } + + return msg, nil +} diff --git a/log/redactrus_test.go b/log/redactrus_test.go new file mode 100755 index 0000000..36a19e2 --- /dev/null +++ b/log/redactrus_test.go @@ -0,0 +1,159 @@ +package log + +import ( + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +var h = &Hook{} + +type levelsTest struct { + name string + acceptedLevels []logrus.Level + expected []logrus.Level + description string +} + +func TestLevels(t *testing.T) { + tests := []levelsTest{ + { + name: "undefinedAcceptedLevels", + acceptedLevels: []logrus.Level{}, + expected: logrus.AllLevels, + description: "All logrus levels expected, but did not receive them", + }, + { + name: "definedAcceptedLevels", + acceptedLevels: []logrus.Level{logrus.InfoLevel}, + expected: []logrus.Level{logrus.InfoLevel}, + description: "Logrus Info level expected, but did not receive that.", + }, + } + + for _, test := range tests { + fn := func(t *testing.T) { + h.AcceptedLevels = test.acceptedLevels + levels := h.Levels() + assert.Equal(t, test.expected, levels, test.description) + } + + t.Run(test.name, fn) + } +} + +type levelThresholdTest struct { + name string + level logrus.Level + expected []logrus.Level + description string +} + +// levelThreshold returns a []logrus.Level including all levels +// above and including the level given. If the provided level does not exit, +// an empty slice is returned. +func levelThreshold(l logrus.Level) []logrus.Level { + //nolint + if l < 0 || int(l) > len(logrus.AllLevels) { + return []logrus.Level{} + } + return logrus.AllLevels[:l+1] +} + +func TestLevelThreshold(t *testing.T) { + tests := []levelThresholdTest{ + { + name: "unknownLogLevel", + level: logrus.Level(100), + expected: []logrus.Level{}, + description: "An empty Level slice was expected but was not returned", + }, + { + name: "errorLogLevel", + level: logrus.ErrorLevel, + expected: []logrus.Level{logrus.PanicLevel, logrus.FatalLevel, logrus.ErrorLevel}, + description: "The panic, fatal, and error levels were expected but were not returned", + }, + } + + for _, test := range tests { + fn := func(t *testing.T) { + levels := levelThreshold(test.level) + assert.Equal(t, test.expected, levels, test.description) + } + + t.Run(test.name, fn) + } +} + +func TestInvalidRegex(t *testing.T) { + e := &logrus.Entry{} + h = &Hook{RedactionList: []string{"\\"}} + err := h.Fire(e) + + assert.NotNil(t, err) +} + +type EntryDataValuesTest struct { + name string + redactionList []string + logFields logrus.Fields + expected logrus.Fields + description string //nolint +} + +// Test that any occurrence of a redaction pattern +// in the values of the entry's data fields is redacted. +func TestEntryDataValues(t *testing.T) { + tests := []EntryDataValuesTest{ + { + name: "match on key", + redactionList: []string{"Password"}, + logFields: logrus.Fields{"Password": "password123!"}, + expected: logrus.Fields{"Password": "[REDACTED]"}, + description: "Password value should have been redacted, but was not.", + }, + { + name: "string value", + redactionList: []string{"William"}, + logFields: logrus.Fields{"Description": "His name is William"}, + expected: logrus.Fields{"Description": "His name is [REDACTED]"}, + description: "William should have been redacted, but was not.", + }, + { + name: "map value", + redactionList: []string{"William"}, + logFields: logrus.Fields{"Description": map[string]string{"name": "His name is William"}}, + expected: logrus.Fields{"Description": "map[name:His name is [REDACTED]]"}, + description: "William should have been redacted, but was not.", + }, + } + + for _, test := range tests { + fn := func(t *testing.T) { + logEntry := &logrus.Entry{ + Data: test.logFields, + } + h = &Hook{RedactionList: test.redactionList} + err := h.Fire(logEntry) + + assert.Nil(t, err) + assert.Equal(t, test.expected, logEntry.Data) + } + t.Run(test.name, fn) + } +} + +// Test that any occurrence of a redaction pattern +// in the entry's Message field is redacted. +func TestEntryMessage(t *testing.T) { + logEntry := &logrus.Entry{ + Message: "Secret Password: password123!", + } + h = &Hook{RedactionList: []string{`(Password: ).*`}} + err := h.Fire(logEntry) + + assert.Nil(t, err) + assert.Equal(t, "Secret Password: [REDACTED]", logEntry.Message) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..65db162 --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + _ "net/http/pprof" //nolint:gosec + + "github.com/navidrome/navidrome/cmd" + "github.com/navidrome/navidrome/conf/buildtags" +) + +//goland:noinspection GoBoolExpressions +func main() { + // This import is used to force the inclusion of the `netgo` tag when compiling the project. + // If you get compilation errors like "undefined: buildtags.NETGO", this means you forgot to specify + // the `netgo` build tag when compiling the project. + // To avoid these kind of errors, you should use `make build` to compile the project. + _ = buildtags.NETGO + + cmd.Execute() +} diff --git a/model/album.go b/model/album.go new file mode 100644 index 0000000..a8dcfe6 --- /dev/null +++ b/model/album.go @@ -0,0 +1,144 @@ +package model + +import ( + "iter" + "math" + "sync" + "time" + + "github.com/gohugoio/hashstructure" +) + +type Album struct { + Annotations `structs:"-" hash:"ignore"` + + 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"` + + // 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 +} + +func (a Album) CoverArtID() ArtworkID { + return artworkIDFromAlbum(a) +} + +// 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 +} + +type DiscID struct { + AlbumID string `json:"albumId"` + ReleaseDate string `json:"releaseDate"` + DiscNumber int `json:"discNumber"` +} + +type Albums []Album + +type AlbumCursor iter.Seq2[Album, error] + +type AlbumRepository interface { + CountAll(...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(*Album) error + UpdateExternalInfo(*Album) error + Get(id string) (*Album, error) + GetAll(...QueryOptions) (Albums, error) + + // The following methods are used exclusively by the scanner: + Touch(ids ...string) error + TouchByMissingFolder() (int64, error) + GetTouchedAlbums(libID int) (AlbumCursor, error) + RefreshPlayCounts() (int64, error) + CopyAttributes(fromID, toID string, columns ...string) error + + AnnotatedRepository + SearchableRepository[Albums] +} diff --git a/model/album_test.go b/model/album_test.go new file mode 100644 index 0000000..a45d16d --- /dev/null +++ b/model/album_test.go @@ -0,0 +1,33 @@ +package model_test + +import ( + "encoding/json" + + . "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Albums", func() { + var albums Albums + + Context("JSON Marshalling", func() { + When("we have a valid Albums object", 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("marshals correctly", func() { + data, err := json.Marshal(albums) + Expect(err).To(BeNil()) + + var albums2 Albums + err = json.Unmarshal(data, &albums2) + Expect(err).To(BeNil()) + Expect(albums2).To(Equal(albums)) + }) + }) + }) +}) diff --git a/model/annotation.go b/model/annotation.go new file mode 100644 index 0000000..fbff5f1 --- /dev/null +++ b/model/annotation.go @@ -0,0 +1,19 @@ +package model + +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"` +} + +type AnnotatedRepository interface { + IncPlayCount(itemID string, ts time.Time) error + SetStar(starred bool, itemIDs ...string) error + SetRating(rating int, itemID string) error + ReassignAnnotation(prevID string, newID string) error +} diff --git a/model/artist.go b/model/artist.go new file mode 100644 index 0000000..309ee80 --- /dev/null +++ b/model/artist.go @@ -0,0 +1,89 @@ +package model + +import ( + "maps" + "slices" + "time" +) + +type Artist struct { + Annotations `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"` + + 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"` +} + +func (a Artist) ArtistImageUrl() string { + if a.LargeImageUrl != "" { + return a.LargeImageUrl + } + if a.MediumImageUrl != "" { + return a.MediumImageUrl + } + return a.SmallImageUrl +} + +func (a Artist) CoverArtID() ArtworkID { + return artworkIDFromArtist(a) +} + +// 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 { + ID string + Artists Artists +} +type ArtistIndexes []ArtistIndex + +type ArtistRepository interface { + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(m *Artist, colsToUpdate ...string) error + UpdateExternalInfo(a *Artist) error + Get(id string) (*Artist, error) + GetAll(options ...QueryOptions) (Artists, error) + GetIndex(includeMissing bool, libraryIds []int, roles ...Role) (ArtistIndexes, error) + + // The following methods are used exclusively by the scanner: + RefreshPlayCounts() (int64, error) + RefreshStats(allArtists bool) (int64, error) + + AnnotatedRepository + SearchableRepository[Artists] +} diff --git a/model/artist_info.go b/model/artist_info.go new file mode 100644 index 0000000..4551432 --- /dev/null +++ b/model/artist_info.go @@ -0,0 +1,13 @@ +package model + +type ArtistInfo struct { + ID string + Name string + MBID string + Biography string + SmallImageUrl string + MediumImageUrl string + LargeImageUrl string + LastFMUrl string + SimilarArtists Artists +} diff --git a/model/artwork_id.go b/model/artwork_id.go new file mode 100644 index 0000000..36026dd --- /dev/null +++ b/model/artwork_id.go @@ -0,0 +1,123 @@ +package model + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type Kind struct { + prefix string + name string +} + +func (k Kind) String() string { + return k.name +} + +var ( + KindMediaFileArtwork = Kind{"mf", "media_file"} + KindArtistArtwork = Kind{"ar", "artist"} + KindAlbumArtwork = Kind{"al", "album"} + KindPlaylistArtwork = Kind{"pl", "playlist"} +) + +var artworkKindMap = map[string]Kind{ + KindMediaFileArtwork.prefix: KindMediaFileArtwork, + KindArtistArtwork.prefix: KindArtistArtwork, + KindAlbumArtwork.prefix: KindAlbumArtwork, + KindPlaylistArtwork.prefix: KindPlaylistArtwork, +} + +type ArtworkID struct { + Kind Kind + ID string + LastUpdate time.Time +} + +func (id ArtworkID) String() string { + if id.ID == "" { + return "" + } + s := fmt.Sprintf("%s-%s", id.Kind.prefix, id.ID) + if lu := id.LastUpdate.Unix(); lu > 0 { + return fmt.Sprintf("%s_%x", s, lu) + } + return s + "_0" +} + +func NewArtworkID(kind Kind, id string, lastUpdate *time.Time) ArtworkID { + artID := ArtworkID{kind, id, time.Time{}} + if lastUpdate != nil { + artID.LastUpdate = *lastUpdate + } + return artID +} + +func ParseArtworkID(id string) (ArtworkID, error) { + parts := strings.SplitN(id, "-", 2) + if len(parts) != 2 { + return ArtworkID{}, errors.New("invalid artwork id") + } + kind, ok := artworkKindMap[parts[0]] + if !ok { + return ArtworkID{}, errors.New("invalid artwork kind") + } + parsedID := ArtworkID{ + Kind: kind, + ID: parts[1], + } + parts = strings.SplitN(parts[1], "_", 2) + if len(parts) == 2 { + 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 +} + +func MustParseArtworkID(id string) ArtworkID { + artID, err := ParseArtworkID(id) + if err != nil { + panic(artID) + } + return artID +} + +func artworkIDFromAlbum(al Album) ArtworkID { + return ArtworkID{ + Kind: KindAlbumArtwork, + ID: al.ID, + LastUpdate: al.UpdatedAt, + } +} + +func artworkIDFromMediaFile(mf MediaFile) ArtworkID { + return ArtworkID{ + Kind: KindMediaFileArtwork, + ID: mf.ID, + LastUpdate: mf.UpdatedAt, + } +} + +func artworkIDFromPlaylist(pls Playlist) ArtworkID { + return ArtworkID{ + Kind: KindPlaylistArtwork, + ID: pls.ID, + LastUpdate: pls.UpdatedAt, + } +} + +func artworkIDFromArtist(ar Artist) ArtworkID { + return ArtworkID{ + Kind: KindArtistArtwork, + ID: ar.ID, + } +} diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go new file mode 100644 index 0000000..2f42217 --- /dev/null +++ b/model/artwork_id_test.go @@ -0,0 +1,59 @@ +package model_test + +import ( + "time" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ArtworkID", func() { + Describe("NewArtworkID()", func() { + 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) + 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())) + }) + }) + 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")) + }) + It("parses media file artwork ids", func() { + id, err := model.ParseArtworkID("mf-a6f8d2b1") + Expect(err).ToNot(HaveOccurred()) + Expect(id.Kind).To(Equal(model.KindMediaFileArtwork)) + Expect(id.ID).To(Equal("a6f8d2b1")) + }) + 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("fails to parse malformed ids", func() { + _, err := model.ParseArtworkID("a6f8d2b1") + Expect(err).To(MatchError("invalid artwork id")) + }) + It("fails to parse ids with invalid kind", func() { + _, err := model.ParseArtworkID("xx-a6f8d2b1") + Expect(err).To(MatchError("invalid artwork kind")) + }) + }) +}) diff --git a/model/bookmark.go b/model/bookmark.go new file mode 100644 index 0000000..7c6637c --- /dev/null +++ b/model/bookmark.go @@ -0,0 +1,24 @@ +package model + +import "time" + +type Bookmarkable struct { + BookmarkPosition int64 `structs:"-" json:"bookmarkPosition"` +} + +type BookmarkableRepository interface { + AddBookmark(id, comment string, position int64) error + DeleteBookmark(id string) error + GetBookmarks() (Bookmarks, error) +} + +type Bookmark struct { + Item MediaFile `structs:"item" json:"item"` + Comment string `structs:"comment" json:"comment"` + Position int64 `structs:"position" json:"position"` + ChangedBy string `structs:"changed_by" json:"changed_by"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +type Bookmarks []Bookmark diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go new file mode 100644 index 0000000..54ac596 --- /dev/null +++ b/model/criteria/criteria.go @@ -0,0 +1,159 @@ +// Package criteria implements a Criteria API based on Masterminds/squirrel +package criteria + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" +) + +type Expression = squirrel.Sqlizer + +type Criteria struct { + Expression + Sort string + Order string + Limit int + Offset int +} + +func (c Criteria) OrderBy() string { + if c.Sort == "" { + c.Sort = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) + order = "" + } + + parts := strings.Split(c.Sort, ",") + fields := make([]string, 0, len(parts)) + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + + dir := "asc" + if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") { + if strings.HasPrefix(p, "-") { + dir = "desc" + } + p = strings.TrimSpace(p[1:]) + } + + sortField := strings.ToLower(p) + f := fieldMap[sortField] + if f == nil { + log.Error("Invalid field in 'sort' field", "sort", sortField) + continue + } + + var mapped string + + if f.order != "" { + mapped = f.order + } else if f.isTag { + // Use the actual field name (handles aliases like albumtype -> releasetype) + tagName := sortField + if f.field != "" { + tagName = f.field + } + mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')" + } else if f.isRole { + mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')" + } else { + mapped = f.field + } + if f.numeric { + mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) + } + // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction. + // This ensures that the global order applies consistently across all fields. + if order == "desc" { + if dir == "asc" { + dir = "desc" + } else { + dir = "asc" + } + } + + fields = append(fields, mapped+" "+dir) + } + + return strings.Join(fields, ", ") +} + +func (c Criteria) ToSql() (sql string, args []any, err error) { + return c.Expression.ToSql() +} + +func (c Criteria) ChildPlaylistIds() []string { + if c.Expression == nil { + return nil + } + + if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil { + return parent.ChildPlaylistIds() + } + + return nil +} + +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"` + Offset int `json:"offset,omitempty"` + }{ + Sort: c.Sort, + Order: c.Order, + Limit: c.Limit, + Offset: c.Offset, + } + switch rules := c.Expression.(type) { + case Any: + aux.Any = rules + case All: + aux.All = rules + default: + aux.All = All{rules} + } + return json.Marshal(aux) +} + +func (c *Criteria) UnmarshalJSON(data []byte) error { + var aux struct { + 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 + } + 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')") + } + c.Sort = aux.Sort + c.Order = aux.Order + c.Limit = aux.Limit + c.Offset = aux.Offset + return nil +} diff --git a/model/criteria/criteria_suite_test.go b/model/criteria/criteria_suite_test.go new file mode 100644 index 0000000..36e74cf --- /dev/null +++ b/model/criteria/criteria_suite_test.go @@ -0,0 +1,17 @@ +package criteria + +import ( + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +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 new file mode 100644 index 0000000..032ead5 --- /dev/null +++ b/model/criteria/criteria_test.go @@ -0,0 +1,248 @@ +package criteria + +import ( + "bytes" + "encoding/json" + + "github.com/google/uuid" + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +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"}, + }, + }, + Sort: "title", + Order: "asc", + Limit: 20, + Offset: 10, + } + var b bytes.Buffer + err := json.Compact(&b, []byte(` +{ + "all": [ + { "contains": {"title": "love"} }, + { "notContains": {"title": "hate"} }, + { "any": [ + { "isNot": {"artist": "u2"} }, + { "is": {"album": "best of"} } + ] + }, + { "all": [ + { "startsWith": {"comment": "this"} }, + { "inTheRange": {"year":[1980,1990]} }, + { "isNot": { "genre": "Rock" }} + ] + } + ], + "sort": "title", + "order": "asc", + "limit": 20, + "offset": 10 +} +`)) + if err != nil { + panic(err) + } + jsonObj = b.String() + }) + It("generates valid SQL", func() { + sql, args, err := goObj.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal( + `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` + + `AND (not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) ` + + `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` + + `AND not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)))`)) + gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock")) + }) + 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)) + }) + Describe("OrderBy", func() { + It("sorts by regular fields", func() { + gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc")) + }) + + It("sorts by tag fields", func() { + goObj.Sort = "genre" + gomega.Expect(goObj.OrderBy()).To( + gomega.Equal( + "COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc", + ), + ) + }) + + It("sorts by role fields", func() { + goObj.Sort = "artist" + gomega.Expect(goObj.OrderBy()).To( + gomega.Equal( + "COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc", + ), + ) + }) + + It("casts numeric tags when sorting", func() { + AddTagNames([]string{"rate"}) + AddNumericTags([]string{"rate"}) + goObj.Sort = "rate" + gomega.Expect(goObj.OrderBy()).To( + gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"), + ) + }) + + It("sorts by albumtype alias (resolves to releasetype)", func() { + AddTagNames([]string{"releasetype"}) + goObj.Sort = "albumtype" + gomega.Expect(goObj.OrderBy()).To( + gomega.Equal( + "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc", + ), + ) + }) + + It("sorts by random", func() { + newObj := goObj + newObj.Sort = "random" + gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc")) + }) + + It("sorts by multiple fields", func() { + goObj.Sort = "title,-rating" + gomega.Expect(goObj.OrderBy()).To(gomega.Equal( + "media_file.title asc, COALESCE(annotation.rating, 0) desc", + )) + }) + + It("reverts order when order is desc", func() { + goObj.Sort = "-date,artist" + goObj.Order = "desc" + gomega.Expect(goObj.OrderBy()).To(gomega.Equal( + "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc", + )) + }) + + It("ignores invalid sort fields", func() { + goObj.Sort = "bogus,title" + gomega.Expect(goObj.OrderBy()).To(gomega.Equal( + "media_file.title asc", + )) + }) + }) + }) + + Context("with artist roles", func() { + BeforeEach(func() { + goObj = Criteria{ + Expression: All{ + Is{"artist": "The Beatles"}, + Contains{"composer": "Lennon"}, + }, + } + }) + + It("generates valid SQL", func() { + sql, args, err := goObj.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal( + `(exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?) AND ` + + `exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?))`, + )) + gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%")) + }) + }) + + Context("with child playlists", func() { + var ( + topLevelInPlaylistID string + topLevelNotInPlaylistID string + nestedAnyInPlaylistID string + nestedAnyNotInPlaylistID string + nestedAllInPlaylistID string + nestedAllNotInPlaylistID string + ) + BeforeEach(func() { + topLevelInPlaylistID = uuid.NewString() + topLevelNotInPlaylistID = uuid.NewString() + + nestedAnyInPlaylistID = uuid.NewString() + nestedAnyNotInPlaylistID = uuid.NewString() + + nestedAllInPlaylistID = uuid.NewString() + nestedAllNotInPlaylistID = uuid.NewString() + + goObj = Criteria{ + Expression: All{ + InPlaylist{"id": topLevelInPlaylistID}, + NotInPlaylist{"id": topLevelNotInPlaylistID}, + Any{ + InPlaylist{"id": nestedAnyInPlaylistID}, + NotInPlaylist{"id": nestedAnyNotInPlaylistID}, + }, + 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 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()) + }) + }) +}) diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go new file mode 100644 index 0000000..9f3f392 --- /dev/null +++ b/model/criteria/export_test.go @@ -0,0 +1,5 @@ +package criteria + +var StartOfPeriod = startOfPeriod + +type UnmarshalConjunctionType = unmarshalConjunctionType diff --git a/model/criteria/fields.go b/model/criteria/fields.go new file mode 100644 index 0000000..5381ae5 --- /dev/null +++ b/model/criteria/fields.go @@ -0,0 +1,243 @@ +package criteria + +import ( + "fmt" + "reflect" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" +) + +var fieldMap = map[string]*mappedField{ + "title": {field: "media_file.title"}, + "album": {field: "media_file.album"}, + "hascoverart": {field: "media_file.has_cover_art"}, + "tracknumber": {field: "media_file.track_number"}, + "discnumber": {field: "media_file.disc_number"}, + "year": {field: "media_file.year"}, + "date": {field: "media_file.date", alias: "recordingdate"}, + "originalyear": {field: "media_file.original_year"}, + "originaldate": {field: "media_file.original_date"}, + "releaseyear": {field: "media_file.release_year"}, + "releasedate": {field: "media_file.release_date"}, + "size": {field: "media_file.size"}, + "compilation": {field: "media_file.compilation"}, + "dateadded": {field: "media_file.created_at"}, + "datemodified": {field: "media_file.updated_at"}, + "discsubtitle": {field: "media_file.disc_subtitle"}, + "comment": {field: "media_file.comment"}, + "lyrics": {field: "media_file.lyrics"}, + "sorttitle": {field: "media_file.sort_title"}, + "sortalbum": {field: "media_file.sort_album_name"}, + "sortartist": {field: "media_file.sort_artist_name"}, + "sortalbumartist": {field: "media_file.sort_album_artist_name"}, + "albumcomment": {field: "media_file.mbz_album_comment"}, + "catalognumber": {field: "media_file.catalog_num"}, + "filepath": {field: "media_file.path"}, + "filetype": {field: "media_file.suffix"}, + "duration": {field: "media_file.duration"}, + "bitrate": {field: "media_file.bit_rate"}, + "bitdepth": {field: "media_file.bit_depth"}, + "bpm": {field: "media_file.bpm"}, + "channels": {field: "media_file.channels"}, + "loved": {field: "COALESCE(annotation.starred, false)"}, + "dateloved": {field: "annotation.starred_at"}, + "lastplayed": {field: "annotation.play_date"}, + "daterated": {field: "annotation.rated_at"}, + "playcount": {field: "COALESCE(annotation.play_count, 0)"}, + "rating": {field: "COALESCE(annotation.rating, 0)"}, + "mbz_album_id": {field: "media_file.mbz_album_id"}, + "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"}, + "mbz_artist_id": {field: "media_file.mbz_artist_id"}, + "mbz_recording_id": {field: "media_file.mbz_recording_id"}, + "mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, + "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, + "library_id": {field: "media_file.library_id", numeric: true}, + + // Backward compatibility: albumtype is an alias for releasetype tag + "albumtype": {field: "releasetype", isTag: true}, + + // special fields + "random": {field: "", order: "random()"}, // pseudo-field for random sorting + "value": {field: "value"}, // pseudo-field for tag and roles values +} + +type mappedField struct { + field string + order string + isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) + isTag bool // true if the field is a tag imported from the file metadata + alias string // name from `mappings.yml` that may differ from the name used in the smart playlist + numeric bool // true if the field/tag should be treated as numeric +} + +func mapFields(expr map[string]any) map[string]any { + m := make(map[string]any) + for f, v := range expr { + if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" { + m[dbf.field] = v + } else { + log.Error("Invalid field in criteria", "field", f) + } + } + return m +} + +// mapExpr maps a normal field expression to a specific type of expression (tag or role). +// This is required because tags are handled differently than other fields, +// as they are stored as a JSON column in the database. +func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer { + rv := reflect.ValueOf(expr) + if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String { + log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr)) + } + + // Extract into a generic map + var k string + m := make(map[string]any, rv.Len()) + for _, key := range rv.MapKeys() { + // Save the key to build the expression, and use the provided keyName as the key + k = key.String() + m["value"] = rv.MapIndex(key).Interface() + break // only one key is expected (and supported) + } + + // Clear the original map + for _, key := range rv.MapKeys() { + rv.SetMapIndex(key, reflect.Value{}) + } + + // Write the updated map back into the original variable + for key, val := range m { + rv.SetMapIndex(reflect.ValueOf(key), reflect.ValueOf(val)) + } + + return exprFunc(k, expr, negate) +} + +// mapTagExpr maps a normal field expression to a tag expression. +func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + return mapExpr(expr, negate, tagExpr) +} + +// mapRoleExpr maps a normal field expression to an artist role expression. +func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + return mapExpr(expr, negate, roleExpr) +} + +func isTagExpr(expr map[string]any) bool { + for f := range expr { + if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag { + return true + } + } + return false +} + +func isRoleExpr(expr map[string]any) bool { + for f := range expr { + if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole { + return true + } + } + return false +} + +func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + return tagCond{tag: tag, cond: cond, not: negate} +} + +type tagCond struct { + tag string + cond squirrel.Sqlizer + not bool +} + +func (e tagCond) ToSql() (string, []any, error) { + cond, args, err := e.cond.ToSql() + + // Resolve the actual tag name (handles aliases like albumtype -> releasetype) + tagName := e.tag + if fm, ok := fieldMap[e.tag]; ok { + if fm.field != "" { + tagName = fm.field + } + if fm.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + } + + cond = fmt.Sprintf("exists (select 1 from json_tree(tags, '$.%s') where key='value' and %s)", + tagName, cond) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + return roleCond{role: role, cond: cond, not: negate} +} + +type roleCond struct { + role string + cond squirrel.Sqlizer + not bool +} + +func (e roleCond) ToSql() (string, []any, error) { + cond, args, err := e.cond.ToSql() + cond = fmt.Sprintf(`exists (select 1 from json_tree(participants, '$.%s') where key='name' and %s)`, + e.role, cond) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +// AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in +// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent. +func AddRoles(roles []string) { + for _, role := range roles { + name := strings.ToLower(role) + if _, ok := fieldMap[name]; ok { + continue + } + fieldMap[name] = &mappedField{field: name, isRole: true} + } +} + +// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` +// file to the field map, so they can be used in smart playlists. +// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent. +func AddTagNames(tagNames []string) { + for _, name := range tagNames { + name := strings.ToLower(name) + if _, ok := fieldMap[name]; ok { + continue + } + for _, fm := range fieldMap { + if fm.alias == name { + fieldMap[name] = fm + break + } + } + if _, ok := fieldMap[name]; !ok { + fieldMap[name] = &mappedField{field: name, isTag: true} + } + } +} + +// AddNumericTags marks the given tag names as numeric so they can be cast +// when used in comparisons or sorting. +func AddNumericTags(tagNames []string) { + for _, name := range tagNames { + name := strings.ToLower(name) + if fm, ok := fieldMap[name]; ok { + fm.numeric = true + } else { + fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true} + } + } +} diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go new file mode 100644 index 0000000..accdebd --- /dev/null +++ b/model/criteria/fields_test.go @@ -0,0 +1,16 @@ +package criteria + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = Describe("fields", func() { + Describe("mapFields", func() { + It("ignores random fields", func() { + m := map[string]any{"random": "123"} + m = mapFields(m) + gomega.Expect(m).To(gomega.BeEmpty()) + }) + }) +}) diff --git a/model/criteria/json.go b/model/criteria/json.go new file mode 100644 index 0000000..f6ab56e --- /dev/null +++ b/model/criteria/json.go @@ -0,0 +1,121 @@ +package criteria + +import ( + "encoding/json" + "fmt" + "strings" +) + +type unmarshalConjunctionType []Expression + +func (uc *unmarshalConjunctionType) UnmarshalJSON(data []byte) error { + var raw []map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + var es unmarshalConjunctionType + for _, e := range raw { + for k, v := range e { + k = strings.ToLower(k) + expr := unmarshalExpression(k, v) + if expr == nil { + expr = unmarshalConjunction(k, v) + } + if expr == nil { + return fmt.Errorf(`invalid expression key '%s'`, k) + } + es = append(es, expr) + } + } + *uc = es + return nil +} + +func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { + m := make(map[string]any) + err := json.Unmarshal(rawValue, &m) + if err != nil { + return nil + } + switch opName { + case "is": + return Is(m) + case "isnot": + return IsNot(m) + case "gt": + return Gt(m) + case "lt": + return Lt(m) + case "contains": + return Contains(m) + case "notcontains": + return NotContains(m) + case "startswith": + return StartsWith(m) + case "endswith": + return EndsWith(m) + case "intherange": + return InTheRange(m) + case "before": + return Before(m) + case "after": + return After(m) + case "inthelast": + return InTheLast(m) + case "notinthelast": + return NotInTheLast(m) + case "inplaylist": + return InPlaylist(m) + case "notinplaylist": + return NotInPlaylist(m) + } + return nil +} + +func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression { + var items unmarshalConjunctionType + err := json.Unmarshal(rawValue, &items) + if err != nil { + return nil + } + switch conjName { + case "any": + return Any(items) + case "all": + return All(items) + } + return nil +} + +func marshalExpression(name string, value map[string]any) ([]byte, error) { + if len(value) != 1 { + return nil, fmt.Errorf(`invalid %s expression length %d for values %v`, name, len(value), value) + } + b := strings.Builder{} + b.WriteString(`{"` + name + `":{`) + for f, v := range value { + j, err := json.Marshal(v) + if err != nil { + return nil, err + } + b.WriteString(`"` + f + `":`) + b.Write(j) + break + } + b.WriteString("}}") + return []byte(b.String()), nil +} + +func marshalConjunction(name string, conj []Expression) ([]byte, error) { + aux := struct { + All []Expression `json:"all,omitempty"` + Any []Expression `json:"any,omitempty"` + }{} + if name == "any" { + aux.Any = conj + } else { + aux.All = conj + } + return json.Marshal(aux) +} diff --git a/model/criteria/operators.go b/model/criteria/operators.go new file mode 100644 index 0000000..336f914 --- /dev/null +++ b/model/criteria/operators.go @@ -0,0 +1,353 @@ +package criteria + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "time" + + "github.com/Masterminds/squirrel" +) + +type ( + All squirrel.And + And = All +) + +func (all All) ToSql() (sql string, args []any, err error) { + return squirrel.And(all).ToSql() +} + +func (all All) MarshalJSON() ([]byte, error) { + return marshalConjunction("all", all) +} + +func (all All) ChildPlaylistIds() (ids []string) { + return extractPlaylistIds(all) +} + +type ( + Any squirrel.Or + Or = Any +) + +func (any Any) ToSql() (sql string, args []any, err error) { + return squirrel.Or(any).ToSql() +} + +func (any Any) MarshalJSON() ([]byte, error) { + return marshalConjunction("any", any) +} + +func (any Any) ChildPlaylistIds() (ids []string) { + return extractPlaylistIds(any) +} + +type Is squirrel.Eq +type Eq = Is + +func (is Is) ToSql() (sql string, args []any, err error) { + if isRoleExpr(is) { + return mapRoleExpr(is, false).ToSql() + } + if isTagExpr(is) { + return mapTagExpr(is, false).ToSql() + } + return squirrel.Eq(mapFields(is)).ToSql() +} + +func (is Is) MarshalJSON() ([]byte, error) { + return marshalExpression("is", is) +} + +type IsNot squirrel.NotEq + +func (in IsNot) ToSql() (sql string, args []any, err error) { + if isRoleExpr(in) { + return mapRoleExpr(squirrel.Eq(in), true).ToSql() + } + if isTagExpr(in) { + return mapTagExpr(squirrel.Eq(in), true).ToSql() + } + return squirrel.NotEq(mapFields(in)).ToSql() +} + +func (in IsNot) MarshalJSON() ([]byte, error) { + return marshalExpression("isNot", in) +} + +type Gt squirrel.Gt + +func (gt Gt) ToSql() (sql string, args []any, err error) { + if isTagExpr(gt) { + return mapTagExpr(gt, false).ToSql() + } + return squirrel.Gt(mapFields(gt)).ToSql() +} + +func (gt Gt) MarshalJSON() ([]byte, error) { + return marshalExpression("gt", gt) +} + +type Lt squirrel.Lt + +func (lt Lt) ToSql() (sql string, args []any, err error) { + if isTagExpr(lt) { + return mapTagExpr(squirrel.Lt(lt), false).ToSql() + } + return squirrel.Lt(mapFields(lt)).ToSql() +} + +func (lt Lt) MarshalJSON() ([]byte, error) { + return marshalExpression("lt", lt) +} + +type Before squirrel.Lt + +func (bf Before) ToSql() (sql string, args []any, err error) { + return Lt(bf).ToSql() +} + +func (bf Before) MarshalJSON() ([]byte, error) { + return marshalExpression("before", bf) +} + +type After Gt + +func (af After) ToSql() (sql string, args []any, err error) { + return Gt(af).ToSql() +} + +func (af After) MarshalJSON() ([]byte, error) { + return marshalExpression("after", af) +} + +type Contains map[string]any + +func (ct Contains) ToSql() (sql string, args []any, err error) { + lk := squirrel.Like{} + for f, v := range mapFields(ct) { + lk[f] = fmt.Sprintf("%%%s%%", v) + } + if isRoleExpr(ct) { + return mapRoleExpr(lk, false).ToSql() + } + if isTagExpr(ct) { + return mapTagExpr(lk, false).ToSql() + } + return lk.ToSql() +} + +func (ct Contains) MarshalJSON() ([]byte, error) { + return marshalExpression("contains", ct) +} + +type NotContains map[string]any + +func (nct NotContains) ToSql() (sql string, args []any, err error) { + lk := squirrel.NotLike{} + for f, v := range mapFields(nct) { + lk[f] = fmt.Sprintf("%%%s%%", v) + } + if isRoleExpr(nct) { + return mapRoleExpr(squirrel.Like(lk), true).ToSql() + } + if isTagExpr(nct) { + return mapTagExpr(squirrel.Like(lk), true).ToSql() + } + return lk.ToSql() +} + +func (nct NotContains) MarshalJSON() ([]byte, error) { + return marshalExpression("notContains", nct) +} + +type StartsWith map[string]any + +func (sw StartsWith) ToSql() (sql string, args []any, err error) { + lk := squirrel.Like{} + for f, v := range mapFields(sw) { + lk[f] = fmt.Sprintf("%s%%", v) + } + if isRoleExpr(sw) { + return mapRoleExpr(lk, false).ToSql() + } + if isTagExpr(sw) { + return mapTagExpr(lk, false).ToSql() + } + return lk.ToSql() +} + +func (sw StartsWith) MarshalJSON() ([]byte, error) { + return marshalExpression("startsWith", sw) +} + +type EndsWith map[string]any + +func (sw EndsWith) ToSql() (sql string, args []any, err error) { + lk := squirrel.Like{} + for f, v := range mapFields(sw) { + lk[f] = fmt.Sprintf("%%%s", v) + } + if isRoleExpr(sw) { + return mapRoleExpr(lk, false).ToSql() + } + if isTagExpr(sw) { + return mapTagExpr(lk, false).ToSql() + } + return lk.ToSql() +} + +func (sw EndsWith) MarshalJSON() ([]byte, error) { + return marshalExpression("endsWith", sw) +} + +type InTheRange map[string]any + +func (itr InTheRange) ToSql() (sql string, args []any, err error) { + and := squirrel.And{} + for f, v := range mapFields(itr) { + s := reflect.ValueOf(v) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v) + } + and = append(and, + squirrel.GtOrEq{f: s.Index(0).Interface()}, + squirrel.LtOrEq{f: s.Index(1).Interface()}, + ) + } + return and.ToSql() +} + +func (itr InTheRange) MarshalJSON() ([]byte, error) { + return marshalExpression("inTheRange", itr) +} + +type InTheLast map[string]any + +func (itl InTheLast) ToSql() (sql string, args []any, 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) +} + +type NotInTheLast map[string]any + +func (nitl NotInTheLast) ToSql() (sql string, args []any, 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 inPeriod(m map[string]any, negate bool) (Expression, error) { + var field string + var value any + for f, v := range mapFields(m) { + field, value = f, v + break + } + str := fmt.Sprintf("%v", value) + v, err := strconv.ParseInt(str, 10, 64) + if err != nil { + return nil, err + } + firstDate := startOfPeriod(v, time.Now()) + + if negate { + return Or{ + squirrel.Lt{field: firstDate}, + squirrel.Eq{field: nil}, + }, nil + } + return squirrel.Gt{field: firstDate}, nil +} + +func startOfPeriod(numDays int64, from time.Time) string { + return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") +} + +type InPlaylist map[string]any + +func (ipl InPlaylist) ToSql() (sql string, args []any, err error) { + return inList(ipl, false) +} + +func (ipl InPlaylist) MarshalJSON() ([]byte, error) { + return marshalExpression("inPlaylist", ipl) +} + +type NotInPlaylist map[string]any + +func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) { + return inList(ipl, true) +} + +func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { + return marshalExpression("notInPlaylist", ipl) +} + +func inList(m map[string]any, negate bool) (sql string, args []any, err error) { + var playlistid string + var ok bool + if playlistid, ok = m["id"].(string); !ok { + return "", nil, errors.New("playlist id not given") + } + + // Subquery to fetch all media files that are contained in given playlist + // Only evaluate playlist if it is public + subQuery := squirrel.Select("media_file_id"). + From("playlist_tracks pl"). + LeftJoin("playlist on pl.playlist_id = playlist.id"). + Where(squirrel.And{ + squirrel.Eq{"pl.playlist_id": playlistid}, + squirrel.Eq{"playlist.public": 1}}) + subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() + + if err != nil { + return "", nil, err + } + if negate { + return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil + } else { + return "media_file.id IN (" + subQText + ")", subQArgs, nil + } +} + +func extractPlaylistIds(inputRule any) (ids []string) { + var id string + var ok bool + + switch rule := inputRule.(type) { + case Any: + for _, rules := range rule { + ids = append(ids, extractPlaylistIds(rules)...) + } + case All: + for _, rules := range rule { + ids = append(ids, extractPlaylistIds(rules)...) + } + case InPlaylist: + if id, ok = rule["id"].(string); ok { + ids = append(ids, id) + } + case NotInPlaylist: + if id, ok = rule["id"].(string); ok { + ids = append(ids, id) + } + } + + return +} diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go new file mode 100644 index 0000000..4c1db13 --- /dev/null +++ b/model/criteria/operators_test.go @@ -0,0 +1,192 @@ +package criteria_test + +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 := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local) + rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local) + + DescribeTable("ToSQL", + func(op Expression, expectedSql string, expectedArgs ...any) { + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal(expectedSql)) + gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...)) + }, + Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), + Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1), + Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), + Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), + Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1), + Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2), + Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), + Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), + Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), + Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), + Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), + Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), + Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd), + Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart), + Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart), + + // InPlaylist and NotInPlaylist are special cases + Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+ + "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+ + "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + + Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())), + Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), + + // Tag tests + Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value > ?)", "A"), + Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value < ?)", "Z"), + Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"), + Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"), + + // Artist roles tests + Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"), + Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value = ?)", "u2"), + Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "John%"), + Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), + ) + + // TODO Validate operators that are not valid for each field type. + XDescribeTable("ToSQL - Invalid Operators", + func(op Expression, expectedError string) { + _, _, err := op.ToSql() + gomega.Expect(err).To(gomega.MatchError(expectedError)) + }, + Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"), + ) + + Describe("Custom Tags", func() { + It("generates valid SQL", func() { + AddTagNames([]string{"mood"}) + op := EndsWith{"mood": "Soft"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.mood') where key='value' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) + }) + It("casts numeric comparisons", func() { + AddNumericTags([]string{"rate"}) + op := Lt{"rate": 6} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) + gomega.Expect(args).To(gomega.HaveExactElements(6)) + }) + It("skips unknown tag names", func() { + op := EndsWith{"unknown": "value"} + sql, args, _ := op.ToSql() + gomega.Expect(sql).To(gomega.BeEmpty()) + gomega.Expect(args).To(gomega.BeEmpty()) + }) + It("supports releasetype as multi-valued tag", func() { + AddTagNames([]string{"releasetype"}) + op := Contains{"releasetype": "soundtrack"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) + }) + It("supports albumtype as alias for releasetype", func() { + AddTagNames([]string{"releasetype"}) + op := Contains{"albumtype": "live"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%live%")) + }) + It("supports albumtype alias with Is operator", func() { + AddTagNames([]string{"releasetype"}) + op := Is{"albumtype": "album"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + // Should query $.releasetype, not $.albumtype + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("album")) + }) + It("supports albumtype alias with IsNot operator", func() { + AddTagNames([]string{"releasetype"}) + op := IsNot{"albumtype": "compilation"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + // Should query $.releasetype, not $.albumtype + gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(tags, '$.releasetype') where key='value' and value = ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("compilation")) + }) + }) + + Describe("Custom Roles", func() { + It("generates valid SQL", func() { + AddRoles([]string{"producer"}) + op := EndsWith{"producer": "Eno"} + sql, args, err := op.ToSql() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(participants, '$.producer') where key='name' and value LIKE ?)")) + gomega.Expect(args).To(gomega.HaveExactElements("%Eno")) + }) + It("skips unknown roles", func() { + op := Contains{"groupie": "Penny Lane"} + sql, args, _ := op.ToSql() + gomega.Expect(sql).To(gomega.BeEmpty()) + gomega.Expect(args).To(gomega.BeEmpty()) + }) + }) + + DescribeTable("JSON Marshaling", + func(op Expression, jsonString string) { + obj := And{op} + newJs, err := json.Marshal(obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(string(newJs)).To(gomega.Equal(fmt.Sprintf(`{"all":[%s]}`, jsonString))) + + var unmarshalObj UnmarshalConjunctionType + js := "[" + jsonString + "]" + err = json.Unmarshal([]byte(js), &unmarshalObj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(unmarshalObj[0]).To(gomega.Equal(op)) + }, + Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`), + Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`), + 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}}`), + Entry("contains", Contains{"title": "Low Rider"}, `{"contains":{"title":"Low Rider"}}`), + 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("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"}}`), + ) +}) diff --git a/model/datastore.go b/model/datastore.go new file mode 100644 index 0000000..7606f7f --- /dev/null +++ b/model/datastore.go @@ -0,0 +1,49 @@ +package model + +import ( + "context" + + "github.com/Masterminds/squirrel" + "github.com/deluan/rest" +) + +type QueryOptions struct { + Sort string + Order string + Max int + Offset int + Filters squirrel.Sqlizer + Seed string // for random sorting +} + +type ResourceRepository interface { + rest.Repository +} + +type DataStore interface { + Library(ctx context.Context) LibraryRepository + Folder(ctx context.Context) FolderRepository + Album(ctx context.Context) AlbumRepository + Artist(ctx context.Context) ArtistRepository + MediaFile(ctx context.Context) MediaFileRepository + Genre(ctx context.Context) GenreRepository + Tag(ctx context.Context) TagRepository + 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 + Scrobble(ctx context.Context) ScrobbleRepository + + Resource(ctx context.Context, model interface{}) ResourceRepository + + WithTx(block func(tx DataStore) error, scope ...string) error + WithTxImmediate(block func(tx DataStore) error, scope ...string) error + GC(ctx context.Context, libraryIDs ...int) error + ReindexAll(ctx context.Context) error +} diff --git a/model/errors.go b/model/errors.go new file mode 100644 index 0000000..41029d3 --- /dev/null +++ b/model/errors.go @@ -0,0 +1,12 @@ +package model + +import "errors" + +var ( + ErrNotFound = errors.New("data not found") + ErrInvalidAuth = errors.New("invalid authentication") + ErrNotAuthorized = errors.New("not authorized") + ErrExpired = errors.New("access expired") + ErrNotAvailable = errors.New("functionality not available") + ErrValidation = errors.New("validation error") +) diff --git a/model/file_types.go b/model/file_types.go new file mode 100644 index 0000000..31590dd --- /dev/null +++ b/model/file_types.go @@ -0,0 +1,30 @@ +package model + +import ( + "mime" + "path/filepath" + "slices" + "strings" +) + +var excludeAudioType = []string{ + "audio/mpegurl", + "audio/x-mpegurl", + "audio/x-scpls", +} + +func IsAudioFile(filePath string) bool { + extension := filepath.Ext(filePath) + mimeType := mime.TypeByExtension(extension) + return !slices.Contains(excludeAudioType, mimeType) && strings.HasPrefix(mimeType, "audio/") +} + +func IsImageFile(filePath string) bool { + extension := filepath.Ext(filePath) + return strings.HasPrefix(mime.TypeByExtension(extension), "image/") +} + +func IsValidPlaylist(filePath string) bool { + extension := strings.ToLower(filepath.Ext(filePath)) + return extension == ".m3u" || extension == ".m3u8" || extension == ".nsp" +} diff --git a/model/file_types_test.go b/model/file_types_test.go new file mode 100644 index 0000000..93301e1 --- /dev/null +++ b/model/file_types_test.go @@ -0,0 +1,61 @@ +package model_test + +import ( + "path/filepath" + + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("File Types()", func() { + Describe("IsAudioFile", func() { + It("returns true for a MP3 file", func() { + Expect(model.IsAudioFile(filepath.Join("path", "to", "test.mp3"))).To(BeTrue()) + }) + + It("returns true for a FLAC file", func() { + Expect(model.IsAudioFile("test.flac")).To(BeTrue()) + }) + + It("returns false for a non-audio file", func() { + Expect(model.IsAudioFile("test.jpg")).To(BeFalse()) + }) + + It("returns false for m3u files", func() { + Expect(model.IsAudioFile("test.m3u")).To(BeFalse()) + }) + + It("returns false for pls files", func() { + Expect(model.IsAudioFile("test.pls")).To(BeFalse()) + }) + }) + + Describe("IsImageFile()", func() { + It("returns true for a PNG file", func() { + Expect(model.IsImageFile(filepath.Join("path", "to", "test.png"))).To(BeTrue()) + }) + + It("returns true for a JPEG file", func() { + Expect(model.IsImageFile("test.JPEG")).To(BeTrue()) + }) + + It("returns false for a non-image file", func() { + Expect(model.IsImageFile("test.mp3")).To(BeFalse()) + }) + }) + + Describe("IsValidPlaylist()", func() { + It("returns true for a M3U file", func() { + Expect(model.IsValidPlaylist(filepath.Join("path", "to", "test.m3u"))).To(BeTrue()) + }) + + It("returns true for a M3U8 file", func() { + Expect(model.IsValidPlaylist(filepath.Join("path", "to", "test.m3u8"))).To(BeTrue()) + }) + + It("returns false for a non-playlist file", func() { + Expect(model.IsValidPlaylist("testm3u")).To(BeFalse()) + }) + }) +}) diff --git a/model/folder.go b/model/folder.go new file mode 100644 index 0000000..7a76973 --- /dev/null +++ b/model/folder.go @@ -0,0 +1,92 @@ +package model + +import ( + "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 +} + +type FolderRepository interface { + Get(id string) (*Folder, error) + GetByPath(lib Library, path string) (*Folder, error) + GetAll(...QueryOptions) ([]Folder, error) + CountAll(...QueryOptions) (int64, error) + GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error) + Put(*Folder) error + MarkMissing(missing bool, ids ...string) error + GetTouchedWithPlaylists() (FolderCursor, error) +} diff --git a/model/folder_test.go b/model/folder_test.go new file mode 100644 index 0000000..0535f69 --- /dev/null +++ b/model/folder_test.go @@ -0,0 +1,119 @@ +package model_test + +import ( + "path" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + . "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() { + 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() { + 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 new file mode 100644 index 0000000..bb05e74 --- /dev/null +++ b/model/genre.go @@ -0,0 +1,14 @@ +package model + +type Genre struct { + ID string `structs:"id" json:"id,omitempty" toml:"id,omitempty" yaml:"id,omitempty"` + Name string `structs:"name" json:"name"` + SongCount int `structs:"-" json:"-" toml:"-" yaml:"-"` + AlbumCount int `structs:"-" json:"-" toml:"-" yaml:"-"` +} + +type Genres []Genre + +type GenreRepository interface { + GetAll(...QueryOptions) (Genres, error) +} diff --git a/model/get_entity.go b/model/get_entity.go new file mode 100644 index 0000000..f51d8c3 --- /dev/null +++ b/model/get_entity.go @@ -0,0 +1,26 @@ +package model + +import ( + "context" +) + +// TODO: Should the type be encoded in the 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 + } + al, err := ds.Album(ctx).Get(id) + if err == nil { + return al, nil + } + 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/id/id.go b/model/id/id.go new file mode 100644 index 0000000..9308752 --- /dev/null +++ b/model/id/id.go @@ -0,0 +1,36 @@ +package id + +import ( + "crypto/md5" + "fmt" + "math/big" + "strings" + + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/navidrome/navidrome/log" +) + +func NewRandom() string { + id, err := gonanoid.Generate("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 22) + if err != nil { + log.Error("Could not generate new ID", err) + } + return id +} + +func NewHash(data ...string) string { + hash := md5.New() + for _, d := range data { + hash.Write([]byte(d)) + hash.Write([]byte(string('\u200b'))) + } + h := hash.Sum(nil) + bi := big.NewInt(0) + bi.SetBytes(h) + s := bi.Text(62) + return fmt.Sprintf("%022s", s) +} + +func NewTagID(name, value string) string { + return NewHash(strings.ToLower(name), strings.ToLower(value)) +} diff --git a/model/library.go b/model/library.go new file mode 100644 index 0000000..bcb2864 --- /dev/null +++ b/model/library.go @@ -0,0 +1,61 @@ +package model + +import ( + "time" + + "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"` +} + +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 { + Get(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(id int) (string, error) + GetAll(...QueryOptions) (Libraries, error) + CountAll(...QueryOptions) (int64, error) + Put(*Library) error + Delete(id int) error + StoreMusicFolder() error + AddArtist(id int, artistID string) error + + // User-library association methods + GetUsersWithLibraryAccess(libraryID int) (Users, error) + + // TODO These methods should be moved to a core service + ScanBegin(id int, fullScan bool) error + ScanEnd(id int) error + ScanInProgress() (bool, error) + RefreshStats(id int) error +} diff --git a/model/lyrics.go b/model/lyrics.go new file mode 100644 index 0000000..f75f3b1 --- /dev/null +++ b/model/lyrics.go @@ -0,0 +1,229 @@ +package model + +import ( + "cmp" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/str" +) + +type Line struct { + Start *int64 `structs:"start,omitempty" json:"start,omitempty"` + Value string `structs:"value" json:"value"` +} + +type Lyrics struct { + DisplayArtist string `structs:"displayArtist,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `structs:"displayTitle,omitempty" json:"displayTitle,omitempty"` + Lang string `structs:"lang" json:"lang"` + Line []Line `structs:"line" json:"line"` + Offset *int64 `structs:"offset,omitempty" json:"offset,omitempty"` + Synced bool `structs:"synced" json:"synced"` +} + +// 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):([^]]+)]`) +) + +func (l Lyrics) IsEmpty() bool { + return len(l.Line) == 0 +} + +func ToLyrics(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 + var timestamps []int64 + + 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 + } + + 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 times == nil || times[0][0] != 0 { + if validLine { + priorLine += "\n" + line + } + continue + } + + if validLine { + for idx := range timestamps { + structuredLines = append(structuredLines, Line{ + Start: ×tamps[idx], + Value: strings.TrimSpace(priorLine), + }) + } + timestamps = nil + } + + 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 { + for idx := range timestamps { + structuredLines = append(structuredLines, Line{ + Start: ×tamps[idx], + Value: strings.TrimSpace(priorLine), + }) + } + } + + // 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: structuredLines, + Offset: offset, + Synced: synced, + } + return &lyrics, nil +} + +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 +} + +type LyricList []Lyrics diff --git a/model/lyrics_test.go b/model/lyrics_test.go new file mode 100644 index 0000000..3829768 --- /dev/null +++ b/model/lyrics_test.go @@ -0,0 +1,119 @@ +package model_test + +import ( + . "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ToLyrics", func() { + It("should parse tags with spaces", func() { + num := int64(1551) + lyrics, err := ToLyrics("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(&num)) + }) + + It("Should ignore bad offset", func() { + lyrics, err := ToLyrics("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() { + a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60) + lyrics, err := ToLyrics("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: &a, Value: "Hi there"}, + {Start: &b, Value: ""}, + {Start: &c, Value: "Test"}, + {Start: &d, Value: "late"}, + })) + }) + + It("Should support multiple timestamps per line", func() { + a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51) + lyrics, err := ToLyrics("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: &a, Value: "Repeated"}, + {Start: &b, Value: "Repeated"}, + {Start: &c, Value: ""}, + {Start: &d, Value: ""}, + })) + }) + + It("Should support parsing multiline string", func() { + a, b := int64(0), int64(10*60*1000+1) + lyrics, err := ToLyrics("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: &a, Value: "This is\na multiline\n\n[:0] string"}, + {Start: &b, Value: "This is\nalso one"}, + })) + }) + + It("Does not match timestamp in middle of line", func() { + lyrics, err := ToLyrics("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() { + a, b := int64(0), int64(1000) + lyrics, err := ToLyrics("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: &a, Value: "This is [00:00:00] be a synced file"}, + {Start: &b, Value: "Line 2"}, + })) + }) + + It("Ignores lines in synchronized lyric prior to first timestamp", func() { + a := int64(0) + lyrics, err := ToLyrics("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: &a, Value: "Text"}, + })) + }) + + It("Handles all possible ms cases", func() { + a, b, c := int64(1), int64(10), int64(100) + lyrics, err := ToLyrics("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: &a, Value: "a"}, + {Start: &b, Value: "b"}, + {Start: &c, Value: "c"}, + })) + }) + + It("Properly sorts repeated lyrics out of order", func() { + a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51) + lyrics, err := ToLyrics("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: &a, Value: "Repeated"}, + {Start: &b, Value: "Test"}, + {Start: &c, Value: "Not repeated"}, + {Start: &d, Value: "Repeated"}, + {Start: &e, Value: "Test"}, + })) + }) +}) diff --git a/model/mediafile.go b/model/mediafile.go new file mode 100644 index 0000000..0ef26d7 --- /dev/null +++ b/model/mediafile.go @@ -0,0 +1,377 @@ +package model + +import ( + "cmp" + "crypto/md5" + "encoding/json" + "fmt" + "iter" + "mime" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/gohugoio/hashstructure" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" +) + +type MediaFile struct { + Annotations `structs:"-" hash:"ignore"` + Bookmarkable `structs:"-" hash:"ignore"` + + 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"` + 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"` + Channels int `structs:"channels" json:"channels"` + 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 conf.Server.Subsonic.AppendSubtitle && mf.Tags[TagSubtitle] != nil { + return fmt.Sprintf("%s (%s)", mf.Title, mf.Tags[TagSubtitle][0]) + } + return mf.Title +} + +func (mf MediaFile) ContentType() string { + return mime.TypeByExtension("." + mf.Suffix) +} + +func (mf MediaFile) CoverArtID() ArtworkID { + // If it has a cover art, return it (if feature is disabled, skip) + if mf.HasCoverArt && conf.Server.EnableMediaFileCoverArt { + return artworkIDFromMediaFile(mf) + } + // if it does not have a coverArt, fallback to the album cover + return mf.AlbumCoverArtID() +} + +func (mf MediaFile) AlbumCoverArtID() ArtworkID { + return artworkIDFromAlbum(Album{ID: mf.AlbumID}) +} + +func (mf MediaFile) StructuredLyrics() (LyricList, error) { + lyrics := LyricList{} + err := json.Unmarshal([]byte(mf.Lyrics), &lyrics) + if err != nil { + return nil, err + } + return lyrics, nil +} + +// String is mainly used for debugging +func (mf MediaFile) String() string { + return mf.Path +} + +// 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 := md5.New() + sum.Write([]byte(fmt.Sprintf("%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) +} + +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{} + } + a := Album{SongCount: len(mfs), Tags: make(Tags), Participants: make(Participants), Discs: Discs{1: ""}} + + // 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)) + tags := make(TagList, 0, len(mfs[0].Tags)*len(mfs)) + + a.Missing = true + embedArtPath := "" + embedArtDisc := 0 + for _, m := range mfs { + // We assume these attributes are all the same for all songs in an album + a.ID = m.AlbumID + a.LibraryID = m.LibraryID + a.Name = m.Album + a.AlbumArtist = m.AlbumArtist + a.AlbumArtistID = m.AlbumArtistID + a.SortAlbumName = m.SortAlbumName + a.SortAlbumArtistName = m.SortAlbumArtistName + a.OrderAlbumName = m.OrderAlbumName + a.OrderAlbumArtistName = m.OrderAlbumArtistName + a.MbzAlbumArtistID = m.MbzAlbumArtistID + a.MbzAlbumType = m.MbzAlbumType + a.MbzAlbumComment = m.MbzAlbumComment + a.CatalogNum = m.CatalogNum + a.Compilation = a.Compilation || m.Compilation + + // Calculated attributes based on aggregations + a.Duration += m.Duration + a.Size += m.Size + years = append(years, m.Year) + dates = append(dates, m.Date) + originalYears = append(originalYears, m.OriginalYear) + originalDates = append(originalDates, m.OriginalDate) + releaseDates = append(releaseDates, m.ReleaseDate) + 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.EmbedArtPath = embedArtPath + a.SetTags(tags) + a.FolderIDs = slice.Unique(slice.Map(mfs, func(m MediaFile) string { return m.FolderID })) + a.Date, _ = allOrNothing(dates) + a.OriginalDate, _ = allOrNothing(originalDates) + a.ReleaseDate, _ = allOrNothing(releaseDates) + a.MinYear, a.MaxYear = minMax(years) + a.MinOriginalYear, a.MaxOriginalYear = minMax(originalYears) + a.Comment, _ = allOrNothing(comments) + a.MbzAlbumID = slice.MostFrequent(mbzAlbumIds) + a.MbzReleaseGroupID = slice.MostFrequent(mbzReleaseGroupIds) + fixAlbumArtist(&a) + + return a +} + +func allOrNothing(items []string) (string, int) { + if len(items) == 0 { + return "", 0 + } + items = slice.Unique(items) + if len(items) != 1 { + return "", len(items) + } + return items[0], 1 +} + +func minMax(items []int) (int, int) { + var mn, mx = items[0], items[0] + for _, value := range items { + mx = max(mx, value) + if mn == 0 { + mn = value + } else if value > 0 { + mn = min(mn, value) + } + } + return mn, mx +} + +func newer(t1, t2 time.Time) time.Time { + if t1.After(t2) { + return t1 + } + return t2 +} + +func older(t1, t2 time.Time) time.Time { + if t1.IsZero() { + return t2 + } + 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) { + if !a.Compilation { + if a.AlbumArtistID == "" { + artist := a.Participants.First(RoleArtist) + a.AlbumArtistID = artist.ID + a.AlbumArtist = artist.Name + } + return + } + albumArtistIds := slice.Map(a.Participants[RoleAlbumArtist], func(p Participant) string { return p.ID }) + if len(slice.Unique(albumArtistIds)) > 1 { + a.AlbumArtist = consts.VariousArtists + a.AlbumArtistID = consts.VariousArtistsID + } +} + +// 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 { + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(m *MediaFile) error + Get(id string) (*MediaFile, error) + GetWithParticipants(id string) (*MediaFile, error) + GetAll(options ...QueryOptions) (MediaFiles, error) + GetCursor(options ...QueryOptions) (MediaFileCursor, error) + Delete(id string) error + DeleteMissing(ids []string) error + DeleteAllMissing() (int64, error) + FindByPaths(paths []string) (MediaFiles, error) + + // The following methods are used exclusively by the scanner: + MarkMissing(bool, ...*MediaFile) error + MarkMissingByFolder(missing bool, folderIDs ...string) error + GetMissingAndMatching(libId int) (MediaFileCursor, error) + FindRecentFilesByMBZTrackID(missing MediaFile, since time.Time) (MediaFiles, error) + FindRecentFilesByProperties(missing MediaFile, since time.Time) (MediaFiles, error) + + AnnotatedRepository + BookmarkableRepository + SearchableRepository[MediaFiles] +} diff --git a/model/mediafile_internal_test.go b/model/mediafile_internal_test.go new file mode 100644 index 0000000..6b7d707 --- /dev/null +++ b/model/mediafile_internal_test.go @@ -0,0 +1,55 @@ +package model + +import ( + "github.com/navidrome/navidrome/consts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("fixAlbumArtist", func() { + var album Album + BeforeEach(func() { + album = Album{Participants: Participants{}} + }) + Context("Non-Compilations", func() { + BeforeEach(func() { + album.Compilation = false + album.Participants.Add(RoleArtist, Artist{ID: "ar-123", Name: "Sparks"}) + }) + 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")) + }) + 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")) + }) + }) + Context("Compilations", func() { + BeforeEach(func() { + album.Compilation = true + album.Name = "Sgt. Pepper Knew My Father" + album.AlbumArtistID = "ar-000" + album.AlbumArtist = "The Beatles" + }) + + 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)) + }) + + 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")) + }) + }) +}) diff --git a/model/mediafile_test.go b/model/mediafile_test.go new file mode 100644 index 0000000..635a61d --- /dev/null +++ b/model/mediafile_test.go @@ -0,0 +1,510 @@ +package model_test + +import ( + "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("MediaFiles", func() { + var mfs MediaFiles + + Describe("ToAlbum", 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", + 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", + }, + } + }) + + It("sets the single values 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")) + }) + }) + 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")}, + } + }) + 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)) + }) + }) + }) + 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"})) + }) + }) + }) + + 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 alphabetically", func() { + album := mfs.ToAlbum() + Expect(album.Tags).To(HaveLen(2)) + Expect(album.Tags).To(HaveKeyWithValue(TagGenre, []string{"Rock", "Alternative", "Punk"})) + Expect(album.Tags).To(HaveKeyWithValue(TagMood, []string{"Chill", "Happy"})) + }) + }) + 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("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")) + }) + }) + }) + }) + }) + + 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")) + }) + + 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() { + 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"}, + } + }) + + DescribeTable("generates correct output", + func(absolutePaths bool, expectedContent string) { + 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", + ), + ) + }) + + Context("path variations", func() { + It("handles different path structures", 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"}, + } + + 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")) + }) + }) + }) +}) + +var _ = Describe("MediaFile", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.EnableMediaFileCoverArt = true + }) + 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 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 its album id if EnableMediaFileCoverArt is disabled", func() { + conf.Server.EnableMediaFileCoverArt = false + mf := MediaFile{ID: "111", AlbumID: "1", HasCoverArt: true} + id := mf.CoverArtID() + Expect(id.Kind).To(Equal(KindAlbumArtwork)) + Expect(id.ID).To(Equal(mf.AlbumID)) + }) + }) +}) + +func t(v string) time.Time { + var timeFormats = []string{"2006-01-02", "2006-01-02 15:04", "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02T15:04", "2006-01-02 15:04:05.999999999 -0700 MST"} + for _, f := range timeFormats { + t, err := time.ParseInLocation(f, v, time.UTC) + if err == nil { + return t.UTC() + } + } + return time.Time{} +} diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go new file mode 100644 index 0000000..18a2735 --- /dev/null +++ b/model/metadata/legacy_ids.go @@ -0,0 +1,57 @@ +package metadata + +import ( + "cmp" + "crypto/md5" + "fmt" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" +) + +// These are the legacy ID functions that were used in the original Navidrome ID generation. +// They are kept here for backwards compatibility with existing databases. + +func legacyTrackID(mf model.MediaFile, prependLibId bool) string { + id := mf.Path + if prependLibId && mf.LibraryID != model.DefaultLibraryID { + id = fmt.Sprintf("%d\\%s", mf.LibraryID, id) + } + return fmt.Sprintf("%x", md5.Sum([]byte(id))) +} + +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) + } + return fmt.Sprintf("%x", md5.Sum([]byte(albumPath))) +} + +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 new file mode 100644 index 0000000..c64e8c7 --- /dev/null +++ b/model/metadata/map_mediafile.go @@ -0,0 +1,185 @@ +package metadata + +import ( + "cmp" + "encoding/json" + "maps" + "math" + "strconv" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/str" +) + +func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile { + mf := model.MediaFile{ + LibraryID: libID, + 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) + mf.BPM = int(math.Round(md.Float(model.TagBPM))) + 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 + mf.BitDepth = md.AudioProperties().BitDepth + mf.Channels = md.AudioProperties().Channels + 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 + mf.PID = md.trackPID(mf) + mf.AlbumID = md.albumID(mf, conf.Server.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)) + + for _, raw := range rawLyrics { + lang := raw.Key() + text := raw.Value() + + lyrics, err := model.ToLyrics(lang, text) + if err != nil { + log.Warn("Unexpected failure occurred when parsing lyrics", "file", md.filePath, err) + continue + } + if !lyrics.IsEmpty() { + lyricList = append(lyricList, *lyrics) + } + } + + 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 new file mode 100644 index 0000000..e3adf3f --- /dev/null +++ b/model/metadata/map_mediafile_test.go @@ -0,0 +1,121 @@ +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/navidrome/navidrome/utils/gg" + . "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(1, "folderID") + } + + 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: P(int64(0))}, + {Value: "English SYLT", Start: P(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)) + }) + }) +}) diff --git a/model/metadata/map_participants.go b/model/metadata/map_participants.go new file mode 100644 index 0000000..e8be6aa --- /dev/null +++ b/model/metadata/map_participants.go @@ -0,0 +1,236 @@ +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 + } + + titleCaser := cases.Title(language.Und) + for _, performer := range md.Pairs(model.TagPerformer) { + name := performer.Value() + subRole := titleCaser.String(performer.Key()) + + 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 +} + +// 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() + } + if len(conf.Split) > 0 { + values = conf.SplitTagValue(values) + return filterDuplicatedOrEmptyValues(values) + } + return 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() + } + if len(conf.Split) > 0 { + vSingle = conf.SplitTagValue(vSingle) + return filterDuplicatedOrEmptyValues(vSingle) + } + return 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 new file mode 100644 index 0000000..71cb9c1 --- /dev/null +++ b/model/metadata/map_participants_test.go @@ -0,0 +1,785 @@ +package metadata_test + +import ( + "os" + + "github.com/google/uuid" + "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(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"), + )) + }) + }) + + 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")) + }) + }) + }) + + 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("")) + } + }) + }) +}) diff --git a/model/metadata/metadata.go b/model/metadata/metadata.go new file mode 100644 index 0000000..1372d00 --- /dev/null +++ b/model/metadata/metadata.go @@ -0,0 +1,387 @@ +package metadata + +import ( + "cmp" + "io/fs" + "math" + "path" + "regexp" + "strconv" + "strings" + "time" + + "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 +} + +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, "/") + t1, t2 := 0, 0 + t1, _ = strconv.Atoi(tuple[0]) + if len(tuple) > 1 { + t2, _ = strconv.Atoi(tuple[1]) + } else { + t2tag := md.first(key + "total") + t2, _ = strconv.Atoi(t2tag) + } + return t1, t2 +} + +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 { + 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 { + 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 strings.HasPrefix(keyStr, prefix) { + keyPart := strings.TrimPrefix(keyStr, prefix) + 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] + } + + 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: + _, err := uuid.Parse(value) + if err != nil { + log.Trace("Invalid UUID tag value", "tag", tagName, "value", value) + return "" + } + } + return value +} diff --git a/model/metadata/metadata_suite_test.go b/model/metadata/metadata_suite_test.go new file mode 100644 index 0000000..fc299c7 --- /dev/null +++ b/model/metadata/metadata_suite_test.go @@ -0,0 +1,32 @@ +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 new file mode 100644 index 0000000..82afd86 --- /dev/null +++ b/model/metadata/metadata_test.go @@ -0,0 +1,298 @@ +package metadata_test + +import ( + "os" + "strings" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/metadata" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/gg" + . "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", 60000)}, + } + 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")) + + // Note: a total of 6 characters are lost from maxLength from + // the key portion and separator + Expect(pair[0].Value()).To(HaveLen(32762)) + }) + + 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"})) + }) + }) + + 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), + ) + + 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"), + )) + }) + }) + + Describe("ReplayGain", func() { + createMF := func(tag, tagValue string) model.MediaFile { + props.Tags = model.RawTags{ + tag: {tagValue}, + } + md = metadata.New(filePath, props) + return md.ToMediaFile(0, "0") + } + + DescribeTable("Gain", + func(tagValue string, expected *float64) { + mf := createMF("replaygain_track_gain", tagValue) + Expect(mf.RGTrackGain).To(Equal(expected)) + }, + Entry("0", "0", gg.P(0.0)), + Entry("1.2dB", "1.2dB", gg.P(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", gg.P(0.0)), + Entry("1.0", "1.0", gg.P(1.0)), + Entry("0.5", "0.5", gg.P(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", gg.P(5.0)), + Entry("-3776", "-3776", gg.P(-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 new file mode 100644 index 0000000..b458829 --- /dev/null +++ b/model/metadata/persistent_ids.go @@ -0,0 +1,106 @@ +package metadata + +import ( + "cmp" + "fmt" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" +) + +type hashFunc = func(...string) string + +// createGetPID returns a function that calculates the persistent ID for a given spec, getting the referenced values from the metadata +// The spec is a pipe-separated list of fields, where each field is a comma-separated list of attributes +// Attributes can be either tags or some processed values like folder, albumid, albumartistid, etc. +// For each field, it gets all its attributes values and concatenates them, then hashes the result. +// If a field is empty, it is skipped and the function looks for the next field. +type getPIDFunc = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string + +func createGetPID(hash hashFunc) getPIDFunc { + var getPID getPIDFunc + getAttr := func(mf model.MediaFile, md Metadata, attr string, prependLibId bool) string { + attr = strings.TrimSpace(strings.ToLower(attr)) + switch attr { + case "albumid": + return getPID(mf, md, conf.Server.PID.Album, prependLibId) + case "folder": + return filepath.Dir(mf.Path) + case "albumartistid": + return hash(str.Clear(strings.ToLower(mf.AlbumArtist))) + case "title": + return mf.Title + case "album": + return str.Clear(strings.ToLower(md.String(model.TagAlbum))) + } + return md.String(model.TagName(attr)) + } + getPID = func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { + pid := "" + fields := strings.Split(spec, "|") + for _, field := range fields { + attributes := strings.Split(field, ",") + hasValue := false + values := slice.Map(attributes, func(attr string) string { + v := getAttr(mf, md, attr, prependLibId) + if v != "" { + hasValue = true + } + return v + }) + if hasValue { + pid += strings.Join(values, "\\") + break + } + } + if prependLibId { + pid = fmt.Sprintf("%d\\%s", mf.LibraryID, pid) + } + return hash(pid) + } + + return func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string { + switch spec { + case "track_legacy": + return legacyTrackID(mf, prependLibId) + case "album_legacy": + return legacyAlbumID(mf, md, prependLibId) + } + return getPID(mf, md, spec, prependLibId) + } +} + +func (md Metadata) trackPID(mf model.MediaFile) string { + return createGetPID(id.NewHash)(mf, md, conf.Server.PID.Track, true) +} + +func (md Metadata) albumID(mf model.MediaFile, pidConf string) string { + return createGetPID(id.NewHash)(mf, md, pidConf, true) +} + +// BFR Must be configurable? +func (md Metadata) artistID(name string) string { + mf := model.MediaFile{AlbumArtist: name} + return createGetPID(id.NewHash)(mf, md, "albumartistid", false) +} + +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 new file mode 100644 index 0000000..7ae0c91 --- /dev/null +++ b/model/metadata/persistent_ids_test.go @@ -0,0 +1,272 @@ +package metadata + +import ( + "strings" + + "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("getPID", func() { + var ( + md Metadata + mf model.MediaFile + sum hashFunc + getPID getPIDFunc + ) + + BeforeEach(func() { + sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" } + getPID = createGetPID(sum) + }) + + 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() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,version,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() { + 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"}, + "version": {"version"}, + "releasedate": {"2021-01-01"}, + } + mf.AlbumArtist = "Album Artist" + Expect(getPID(mf, md, spec, false)).To(Equal("(((album artist)\\album name\\version\\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)")) + }) + }) + }) + + 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() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.PID.Album = "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() { + 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)) + }) + }) + }) + }) +}) diff --git a/model/model_suite_test.go b/model/model_suite_test.go new file mode 100644 index 0000000..39184ed --- /dev/null +++ b/model/model_suite_test.go @@ -0,0 +1,18 @@ +package model_test + +import ( + "testing" + + _ "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 TestModel(t *testing.T) { + tests.Init(t, true) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Model Suite") +} diff --git a/model/participants.go b/model/participants.go new file mode 100644 index 0000000..afbda10 --- /dev/null +++ b/model/participants.go @@ -0,0 +1,199 @@ +package model + +import ( + "cmp" + "crypto/md5" + "fmt" + "slices" + "strings" + + "github.com/navidrome/navidrome/utils/slice" +) + +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 := md5.New() + sum.Write([]byte(strings.Join(flattened, "|"))) + return sum.Sum(nil) +} diff --git a/model/participants_test.go b/model/participants_test.go new file mode 100644 index 0000000..dad84b6 --- /dev/null +++ b/model/participants_test.go @@ -0,0 +1,214 @@ +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 new file mode 100644 index 0000000..39ea99d --- /dev/null +++ b/model/player.go @@ -0,0 +1,31 @@ +package model + +import ( + "time" +) + +type Player struct { + Username string `structs:"-" json:"userName"` + + ID string `structs:"id" json:"id"` + Name string `structs:"name" json:"name"` + UserAgent string `structs:"user_agent" json:"userAgent"` + UserId string `structs:"user_id" json:"userId"` + Client string `structs:"client" json:"client"` + IP string `structs:"ip" json:"ip"` + 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"` +} + +type Players []Player + +type PlayerRepository interface { + Get(id string) (*Player, error) + FindMatch(userId, client, userAgent string) (*Player, error) + Put(p *Player) error + CountAll(...QueryOptions) (int64, error) + CountByClient(...QueryOptions) (map[string]int64, error) +} diff --git a/model/playlist.go b/model/playlist.go new file mode 100644 index 0000000..a87019e --- /dev/null +++ b/model/playlist.go @@ -0,0 +1,153 @@ +package model + +import ( + "slices" + "strconv" + "time" + + "github.com/navidrome/navidrome/model/criteria" +) + +type Playlist struct { + 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"` + 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"` +} + +func (pls Playlist) IsSmartPlaylist() bool { + return pls.Rules != nil && pls.Rules.Expression != nil +} + +func (pls Playlist) MediaFiles() MediaFiles { + if len(pls.Tracks) == 0 { + return nil + } + 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 { + if slices.Contains(idxToRemove, i) { + continue + } + newTracks = append(newTracks, t) + } + pls.Tracks = newTracks + pls.refreshStats() +} + +// ToM3U8 exports the playlist to the Extended M3U8 format +func (pls *Playlist) ToM3U8() string { + return pls.MediaFiles().ToM3U8(pls.Name, true) +} + +func (pls *Playlist) AddMediaFilesByID(mediaFileIds []string) { + pos := len(pls.Tracks) + for _, mfId := range mediaFileIds { + pos++ + t := PlaylistTrack{ + ID: strconv.Itoa(pos), + MediaFileID: mfId, + MediaFile: MediaFile{ID: mfId}, + PlaylistID: pls.ID, + } + pls.Tracks = append(pls.Tracks, t) + } + pls.refreshStats() +} + +func (pls *Playlist) AddMediaFiles(mfs MediaFiles) { + pos := len(pls.Tracks) + for _, mf := range mfs { + pos++ + t := PlaylistTrack{ + ID: strconv.Itoa(pos), + MediaFileID: mf.ID, + MediaFile: mf, + PlaylistID: pls.ID, + } + pls.Tracks = append(pls.Tracks, t) + } + pls.refreshStats() +} + +func (pls Playlist) CoverArtID() ArtworkID { + return artworkIDFromPlaylist(pls) +} + +type Playlists []Playlist + +type PlaylistRepository interface { + ResourceRepository + CountAll(options ...QueryOptions) (int64, error) + Exists(id string) (bool, error) + Put(pls *Playlist) error + Get(id string) (*Playlist, error) + GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) + GetAll(options ...QueryOptions) (Playlists, error) + FindByPath(path string) (*Playlist, error) + Delete(id string) error + Tracks(playlistId string, refreshSmartPlaylist bool) PlaylistTrackRepository + GetPlaylists(mediaFileId string) (Playlists, error) +} + +type PlaylistTrack struct { + ID string `json:"id"` + MediaFileID string `json:"mediaFileId"` + PlaylistID string `json:"playlistId"` + MediaFile +} + +type PlaylistTracks []PlaylistTrack + +func (plt PlaylistTracks) MediaFiles() MediaFiles { + mfs := make(MediaFiles, len(plt)) + for i, t := range plt { + mfs[i] = t.MediaFile + } + return mfs +} + +type PlaylistTrackRepository interface { + 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 new file mode 100644 index 0000000..a54cecd --- /dev/null +++ b/model/playlist_test.go @@ -0,0 +1,44 @@ +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, + 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() { + 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 new file mode 100644 index 0000000..03b5622 --- /dev/null +++ b/model/playqueue.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" +) + +type PlayQueue struct { + ID string `structs:"id" json:"id"` + UserID string `structs:"user_id" json:"userId"` + Current int `structs:"current" json:"current"` + Position int64 `structs:"position" json:"position"` + ChangedBy string `structs:"changed_by" json:"changedBy"` + Items MediaFiles `structs:"-" json:"items,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +type PlayQueues []PlayQueue + +type PlayQueueRepository interface { + Store(queue *PlayQueue, colNames ...string) error + // Retrieve returns the playqueue without loading the full MediaFiles + // (Items only contain IDs) + Retrieve(userId string) (*PlayQueue, error) + // RetrieveWithMediaFiles returns the playqueue with full MediaFiles loaded + RetrieveWithMediaFiles(userId string) (*PlayQueue, error) + Clear(userId string) error +} diff --git a/model/properties.go b/model/properties.go new file mode 100644 index 0000000..06bb9eb --- /dev/null +++ b/model/properties.go @@ -0,0 +1,8 @@ +package model + +type PropertyRepository interface { + 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 new file mode 100644 index 0000000..567d32e --- /dev/null +++ b/model/radio.go @@ -0,0 +1,23 @@ +package model + +import "time" + +type Radio struct { + 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"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +type Radios []Radio + +type RadioRepository interface { + 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/request/request.go b/model/request/request.go new file mode 100644 index 0000000..8d79192 --- /dev/null +++ b/model/request/request.go @@ -0,0 +1,127 @@ +package request + +import ( + "context" + + "github.com/navidrome/navidrome/model" +) + +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 +) + +var allKeys = []contextKey{ + User, + Username, + Client, + Version, + Player, + Transcoding, + ClientUniqueId, + ReverseProxyIp, + InternalAuth, +} + +func WithUser(ctx context.Context, u model.User) context.Context { + return context.WithValue(ctx, User, u) +} + +func WithUsername(ctx context.Context, username string) context.Context { + return context.WithValue(ctx, Username, username) +} + +func WithClient(ctx context.Context, client string) context.Context { + return context.WithValue(ctx, Client, client) +} + +func WithVersion(ctx context.Context, version string) context.Context { + return context.WithValue(ctx, Version, version) +} + +func WithPlayer(ctx context.Context, player model.Player) context.Context { + return context.WithValue(ctx, Player, player) +} + +func WithTranscoding(ctx context.Context, t model.Transcoding) context.Context { + return context.WithValue(ctx, Transcoding, t) +} + +func WithClientUniqueId(ctx context.Context, clientUniqueId string) context.Context { + 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) +} + +func UserFrom(ctx context.Context) (model.User, bool) { + v, ok := ctx.Value(User).(model.User) + return v, ok +} + +func UsernameFrom(ctx context.Context) (string, bool) { + v, ok := ctx.Value(Username).(string) + return v, ok +} + +func ClientFrom(ctx context.Context) (string, bool) { + v, ok := ctx.Value(Client).(string) + return v, ok +} + +func VersionFrom(ctx context.Context) (string, bool) { + v, ok := ctx.Value(Version).(string) + return v, ok +} + +func PlayerFrom(ctx context.Context) (model.Player, bool) { + v, ok := ctx.Value(Player).(model.Player) + return v, ok +} + +func TranscodingFrom(ctx context.Context) (model.Transcoding, bool) { + v, ok := ctx.Value(Transcoding).(model.Transcoding) + return v, ok +} + +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 +} diff --git a/model/scanner.go b/model/scanner.go new file mode 100644 index 0000000..389c77f --- /dev/null +++ b/model/scanner.go @@ -0,0 +1,81 @@ +package model + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) + +// 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 // Relative path within the library, 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 + colonIdx := strings.Index(part, ":") + if colonIdx == -1 { + return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part) + } + + libIDStr := part[:colonIdx] + folderPath := part[colonIdx+1:] + + 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 new file mode 100644 index 0000000..8ca0c53 --- /dev/null +++ b/model/scanner_test.go @@ -0,0 +1,89 @@ +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 new file mode 100644 index 0000000..e1567ab --- /dev/null +++ b/model/scrobble.go @@ -0,0 +1,13 @@ +package model + +import "time" + +type Scrobble struct { + MediaFileID string + UserID string + SubmissionTime time.Time +} + +type ScrobbleRepository interface { + RecordScrobble(mediaFileID string, submissionTime time.Time) error +} diff --git a/model/scrobble_buffer.go b/model/scrobble_buffer.go new file mode 100644 index 0000000..c75a828 --- /dev/null +++ b/model/scrobble_buffer.go @@ -0,0 +1,23 @@ +package model + +import "time" + +type ScrobbleEntry struct { + ID string + Service string + UserID string + PlayTime time.Time + EnqueueTime time.Time + MediaFileID string + MediaFile +} + +type ScrobbleEntries []ScrobbleEntry + +type ScrobbleBufferRepository interface { + 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 new file mode 100644 index 0000000..631a117 --- /dev/null +++ b/model/searchable.go @@ -0,0 +1,5 @@ +package model + +type SearchableRepository[T any] interface { + Search(q string, offset, size int, options ...QueryOptions) (T, error) +} diff --git a/model/share.go b/model/share.go new file mode 100644 index 0000000..acb5fb4 --- /dev/null +++ b/model/share.go @@ -0,0 +1,62 @@ +package model + +import ( + "cmp" + "strings" + "time" + + "github.com/navidrome/navidrome/utils/random" +) + +type Share struct { + ID string `structs:"id" json:"id,omitempty"` + UserID string `structs:"user_id" json:"userId,omitempty"` + Username string `structs:"-" json:"username,omitempty"` + 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"` + 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,omitempty"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt,omitempty"` + Tracks MediaFiles `structs:"-" json:"tracks,omitempty"` + Albums Albums `structs:"-" json:"albums,omitempty"` + URL string `structs:"-" json:"-"` + ImageURL string `structs:"-" json:"-"` +} + +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() + case "playlist": + return Playlist{ID: ids[0]}.CoverArtID() + case "artist": + return Artist{ID: ids[0]}.CoverArtID() + } + rnd := random.Int64N(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 { + Exists(id string) (bool, error) + Get(id string) (*Share, error) + GetAll(options ...QueryOptions) (Shares, error) + CountAll(options ...QueryOptions) (int64, error) +} diff --git a/model/tag.go b/model/tag.go new file mode 100644 index 0000000..674f688 --- /dev/null +++ b/model/tag.go @@ -0,0 +1,257 @@ +package model + +import ( + "cmp" + "crypto/md5" + "fmt" + "slices" + "strings" + + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" +) + +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{} + for _, 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 + } + + 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(values[a], values[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) 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 := md5.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) { + for _, existing := range t[name] { + if existing == v { + return + } + } + t[name] = append(t[name], v) +} + +type TagRepository interface { + Add(libraryID int, tags ...Tag) error + UpdateCounts() 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" + + // 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" + + 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 new file mode 100644 index 0000000..bfe098f --- /dev/null +++ b/model/tag_mappings.go @@ -0,0 +1,246 @@ +package model + +import ( + "cmp" + "maps" + "regexp" + "slices" + "strings" + "sync" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "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:"-"` +} + +// SplitTagValue splits a tag value by the split separators, but only if it has a single value. +func (c TagConf) SplitTagValue(values []string) []string { + // If there's not exactly one value or no separators, return early. + if len(values) != 1 || c.SplitRx == nil { + return values + } + tag := values[0] + + // Replace all occurrences of any separator with the zero-width space. + tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) + + // Split by the zero-width space and trim each substring. + parts := strings.Split(tag, consts.Zwsp) + for i, part := range parts { + parts[i] = strings.TrimSpace(part) + } + return parts +} + +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 +} + +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_test.go b/model/tag_test.go new file mode 100644 index 0000000..c01aa0b --- /dev/null +++ b/model/tag_test.go @@ -0,0 +1,120 @@ +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")) + }) + + 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 sort tags by name 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{"Alternative", "Jazz", "Pop", "Rock"})) + }) + 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 new file mode 100644 index 0000000..9b81a7c --- /dev/null +++ b/model/transcoding.go @@ -0,0 +1,18 @@ +package model + +type Transcoding struct { + ID string `structs:"id" json:"id"` + Name string `structs:"name" json:"name"` + TargetFormat string `structs:"target_format" json:"targetFormat"` + Command string `structs:"command" json:"command"` + DefaultBitRate int `structs:"default_bit_rate" json:"defaultBitRate"` +} + +type Transcodings []Transcoding + +type TranscodingRepository interface { + 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 new file mode 100644 index 0000000..c590ba2 --- /dev/null +++ b/model/user.go @@ -0,0 +1,61 @@ +package model + +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"` + + // Library associations (many-to-many relationship) + Libraries Libraries `structs:"-" json:"libraries,omitempty"` + + // This is only available on the backend, and it is never sent over the wire + Password string `structs:"-" json:"-"` + // 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"` + // 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 { + ResourceRepository + CountAll(...QueryOptions) (int64, error) + Delete(id string) 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(username string) (*User, error) + // FindByUsernameWithPassword is the same as above, but also returns the decrypted password + FindByUsernameWithPassword(username string) (*User, error) + + // Library association methods + GetUserLibraries(userID string) (Libraries, error) + SetUserLibraries(userID string, libraryIDs []int) error +} diff --git a/model/user_props.go b/model/user_props.go new file mode 100644 index 0000000..c2eb536 --- /dev/null +++ b/model/user_props.go @@ -0,0 +1,8 @@ +package model + +type UserPropsRepository interface { + 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 new file mode 100644 index 0000000..ab66a29 --- /dev/null +++ b/model/user_test.go @@ -0,0 +1,83 @@ +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 new file mode 100644 index 0000000..852f0b7 --- /dev/null +++ b/persistence/album_repository.go @@ -0,0 +1,442 @@ +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + "sync" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "github.com/pocketbase/dbx" +) + +type albumRepository struct { + sqlRepository + ms *MeilisearchService +} + +type dbAlbum struct { + *model.Album `structs:",flatten"` + Discs string `structs:"-" json:"discs"` + Participants string `structs:"-" json:"-"` + Tags string `structs:"-" json:"-"` + FolderIDs string `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 + } + return nil +} + +func (a *dbAlbum) PostMapArgs(args map[string]any) error { + fullText := []string{a.Name, a.SortAlbumName, a.AlbumArtist} + fullText = append(fullText, a.Album.Participants.AllNames()...) + 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["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(ctx context.Context, db dbx.Builder, ms *MeilisearchService) model.AlbumRepository { + r := &albumRepository{} + r.ctx = ctx + r.db = db + r.ms = ms + 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, name", + "random": "random", + "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"), + "compilation": booleanFilter, + "artist_id": artistFilter, + "year": yearFilter, + "recently_played": recentlyPlayedFilter, + "starred": booleanFilter, + "has_rating": hasRatingFilter, + "missing": booleanFilter, + "genre_id": tagIDFilter, + "role_total_id": allRolesFilter, + "library_id": libraryIdFilter, + } + // Add all album tags as filters + for tag := range model.AlbumLevelTags() { + filters[string(tag)] = tagIDFilter + } + + for role := range model.AllRoles { + filters["role_"+role+"_id"] = artistRoleFilter + } + + return filters +}) + +func recentlyAddedSort() string { + if conf.Server.RecentlyAddedByModTime { + return "updated_at" + } + return "created_at" +} + +func recentlyPlayedFilter(string, interface{}) Sqlizer { + return Gt{"play_count": 0} +} + +func hasRatingFilter(string, interface{}) Sqlizer { + return Gt{"rating": 0} +} + +func yearFilter(_ string, value interface{}) Sqlizer { + return Or{ + And{ + Gt{"min_year": 0}, + LtOrEq{"min_year": value}, + GtOrEq{"max_year": value}, + }, + Eq{"max_year": value}, + } +} + +func artistFilter(_ string, value interface{}) Sqlizer { + return Or{ + Exists("json_tree(participants, '$.albumartist')", Eq{"value": value}), + Exists("json_tree(participants, '$.artist')", Eq{"value": value}), + } +} + +func artistRoleFilter(name string, value interface{}) Sqlizer { + roleName := strings.TrimSuffix(strings.TrimPrefix(name, "role_"), "_id") + + // Check if the role name is valid. If not, return an invalid filter + if _, ok := model.AllRoles[roleName]; !ok { + return Gt{"": nil} + } + return Exists(fmt.Sprintf("json_tree(participants, '$.%s')", roleName), Eq{"value": value}) +} + +func allRolesFilter(_ string, value interface{}) Sqlizer { + return Like{"participants": fmt.Sprintf(`%%"%s"%%`, value)} +} + +func (r *albumRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := r.newSelect() + query = r.withAnnotation(query, "album.id") + query = r.applyLibraryFilter(query) + return r.count(query, options...) +} + +func (r *albumRepository) Exists(id string) (bool, error) { + return r.exists(Eq{"album.id": id}) +} + +func (r *albumRepository) Put(al *model.Album) error { + al.ImportedAt = time.Now() + id, err := r.put(al.ID, &dbAlbum{Album: al}) + if err != nil { + return err + } + al.ID = id + if len(al.Participants) > 0 { + err = r.updateParticipants(al.ID, al.Participants) + if err != nil { + return err + } + } + if r.ms != nil { + r.ms.IndexAlbum(al) + } + return err +} + +// TODO Move external metadata to a separated table +func (r *albumRepository) UpdateExternalInfo(al *model.Album) error { + _, err := r.put(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(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(options...).Columns("album.*", "library.path as library_path", "library.name as library_name"). + LeftJoin("library on album.library_id = library.id") + sql = r.withAnnotation(sql, "album.id") + return r.applyLibraryFilter(sql) +} + +func (r *albumRepository) Get(id string) (*model.Album, error) { + res, err := r.GetAll(model.QueryOptions{Filters: Eq{"album.id": id}}) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, model.ErrNotFound + } + return &res[0], nil +} + +func (r *albumRepository) GetAll(options ...model.QueryOptions) (model.Albums, error) { + sq := r.selectAlbum(options...) + var res dbAlbums + err := r.queryAll(sq, &res) + if err != nil { + return nil, err + } + return res.toModels(), err +} + +func (r *albumRepository) CopyAttributes(fromID, toID string, columns ...string) error { + var from dbx.NullStringMap + err := r.queryOne(Select(columns...).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]interface{}) + for _, col := range columns { + to[col] = from[col] + } + _, err = r.executeSQL(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(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(upd) + if err != nil { + return fmt.Errorf("error touching albums: %w", err) + } + log.Debug(r.ctx, "Touching albums", "ids", ids, "updated", c) + } + return nil +} + +// TouchByMissingFolder touches all albums that have missing folders +func (r *albumRepository) TouchByMissingFolder() (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(upd) + if err != nil { + return 0, fmt.Errorf("error touching albums by missing folder: %w", err) + } + return c, nil +} + +// 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(libID int) (model.AlbumCursor, error) { + query := r.selectAlbum(). + Where(And{ + Eq{"library.id": libID}, + ConcatExpr("album.imported_at > library.last_scan_at"), + }) + cursor, err := queryWithStableResults[dbAlbum](r.sqlRepository, query) + if err != nil { + return nil, err + } + return func(yield func(model.Album, error) bool) { + for a, err := range cursor { + if a.Album == nil { + yield(model.Album{}, fmt.Errorf("unexpected nil album: %v", a)) + return + } + if !yield(*a.Album, err) || err != nil { + return + } + } + }, nil +} + +// 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() (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(query) +} + +func (r *albumRepository) purgeEmpty(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(del) + if err != nil { + return fmt.Errorf("purging empty albums: %w", err) + } + // TODO: Delete from Meilisearch. + // Since purgeEmpty executes a DELETE statement without returning IDs, we can't easily sync Meilisearch here. + // Ideally we should select IDs first, then delete. But this is a cleanup task. + // For now we skip Meilisearch deletion here as it requires more changes. + // The stale entries in Meilisearch will just return empty results when fetched from DB, which Search handles. + if c > 0 { + log.Debug(r.ctx, "Purged empty albums", "totalDeleted", c) + } + return nil +} + +func (r *albumRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { + var res dbAlbums + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectAlbum(options...), q, []string{"mbz_album_id", "mbz_release_group_id"}, &res) + if err != nil { + return nil, fmt.Errorf("searching album by MBID %q: %w", q, err) + } + } else { + if r.ms != nil { + ids, err := r.ms.Search("albums", q, offset, size) + if err == nil { + if len(ids) == 0 { + return model.Albums{}, nil + } + // Fetch matching albums from the database + albums, err := r.GetAll(model.QueryOptions{Filters: Eq{"album.id": ids}}) + if err != nil { + return nil, fmt.Errorf("fetching albums from meilisearch ids: %w", err) + } + // Reorder results to match Meilisearch order + idMap := make(map[string]model.Album, len(albums)) + for _, a := range albums { + idMap[a.ID] = a + } + sorted := make(model.Albums, 0, len(albums)) + for _, id := range ids { + if a, ok := idMap[id]; ok { + sorted = append(sorted, a) + } + } + return sorted, nil + } + log.Warn(r.ctx, "Meilisearch search failed, falling back to SQL", "error", err) + } + err := r.doSearch(r.selectAlbum(options...), q, offset, size, &res, "album.rowid", "name") + if err != nil { + return nil, fmt.Errorf("searching album by query %q: %w", q, err) + } + } + return res.toModels(), nil +} + +func (r *albumRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *albumRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *albumRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + if len(options) > 0 && r.ms != nil { + if name, ok := options[0].Filters["name"].(string); ok && name != "" { + ids, err := r.ms.Search("albums", name, 0, 10000) + if err == nil { + log.Debug(r.ctx, "Meilisearch found matches", "count", len(ids), "query", name) + delete(options[0].Filters, "name") + options[0].Filters["id"] = ids + } else { + log.Warn(r.ctx, "Meilisearch search failed, falling back to SQL", "error", err) + } + } + } + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *albumRepository) EntityName() string { + return "album" +} + +func (r *albumRepository) NewInstance() interface{} { + return &model.Album{} +} + +var _ model.AlbumRepository = (*albumRepository)(nil) +var _ model.ResourceRepository = (*albumRepository)(nil) diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go new file mode 100644 index 0000000..b5233e5 --- /dev/null +++ b/persistence/album_repository_test.go @@ -0,0 +1,524 @@ +package persistence + +import ( + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "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("AlbumRepository", func() { + var albumRepo *albumRepository + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), model.User{ID: "userid", UserName: "johndoe"}) + albumRepo = NewAlbumRepository(ctx, GetDBXBuilder(), nil).(*albumRepository) + }) + + Describe("Get", func() { + var Get = func(id string) (*model.Album, error) { + album, err := albumRepo.Get(id) + if album != nil { + album.ImportedAt = time.Time{} + } + return album, err + } + It("returns an existent album", func() { + Expect(Get("103")).To(Equal(&albumRadioactivity)) + }) + It("returns ErrNotFound when the album does not exist", func() { + _, err := Get("666") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + var GetAll = func(opts ...model.QueryOptions) (model.Albums, error) { + albums, err := albumRepo.GetAll(opts...) + for i := range albums { + albums[i].ImportedAt = time.Time{} + } + return albums, err + } + + It("returns all records", func() { + Expect(GetAll()).To(Equal(testAlbums)) + }) + + It("returns all records sorted", func() { + Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{ + albumAbbeyRoad, + albumMultiDisc, + albumRadioactivity, + albumSgtPeppers, + })) + }) + + It("returns all records sorted desc", func() { + Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{ + albumSgtPeppers, + albumRadioactivity, + albumMultiDisc, + albumAbbeyRoad, + })) + }) + + It("paginates the result", func() { + Expect(GetAll(model.QueryOptions{Offset: 1, Max: 1})).To(Equal(model.Albums{ + albumAbbeyRoad, + })) + }) + }) + + 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(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + for i := 0; i < playCount; i++ { + Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) + } + + album, err := albumRepo.Get(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(&model.Album{LibraryID: 1, ID: newID, Name: "name", SongCount: songCount})).To(Succeed()) + for i := 0; i < playCount; i++ { + Expect(albumRepo.IncPlayCount(newID, time.Now())).To(Succeed()) + } + + album, err := albumRepo.Get(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("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, expectedSQL string) { + sqlizer := artistRoleFilter(filterName, artistID) + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal(expectedSQL)) + Expect(args).To(Equal([]interface{}{artistID})) + }, + Entry("artist role", "role_artist_id", "123", + "exists (select 1 from json_tree(participants, '$.artist') where value = ?)"), + Entry("albumartist role", "role_albumartist_id", "456", + "exists (select 1 from json_tree(participants, '$.albumartist') where value = ?)"), + Entry("composer role", "role_composer_id", "789", + "exists (select 1 from json_tree(participants, '$.composer') where value = ?)"), + ) + + 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(fmt.Sprintf("exists (select 1 from json_tree(participants, '$.%s') where value = ?)", roleName))) + Expect(args).To(Equal([]interface{}{"test-id"})) + } + }) + + 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 + + BeforeEach(func() { + ctx := request.WithUser(GinkgoT().Context(), adminUser) + artistRepo = NewArtistRepository(ctx, GetDBXBuilder(), nil).(*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(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(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(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(squirrel.Delete("artist").Where(squirrel.Eq{"id": artist.ID})) + _, _ = albumRepo.executeSQL(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + + 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(artistRepo, artist1, 1) + Expect(err).ToNot(HaveOccurred()) + err = createArtistWithLibrary(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(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(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(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(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(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(squirrel.Delete("artist").Where(squirrel.Eq{"id": artistIDs})) + _, _ = albumRepo.executeSQL(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(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(squirrel.Delete("album").Where(squirrel.Eq{"id": album.ID})) + }) + }) +}) + +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 new file mode 100644 index 0000000..d205216 --- /dev/null +++ b/persistence/artist_repository.go @@ -0,0 +1,608 @@ +package persistence + +import ( + "cmp" + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" + . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/slice" + "github.com/pocketbase/dbx" +) + +type artistRepository struct { + sqlRepository + indexGroups utils.IndexGroups + ms *MeilisearchService +} + +type dbArtist struct { + *model.Artist `structs:",flatten"` + SimilarArtists string `structs:"-" json:"-"` + LibraryStatsJSON string `structs:"-" json:"-"` +} + +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) + m["full_text"] = formatFullText(a.Name, a.SortArtistName) + + // 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(ctx context.Context, db dbx.Builder, ms *MeilisearchService) model.ArtistRepository { + r := &artistRepository{} + r.ctx = ctx + r.db = db + r.ms = ms + 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": booleanFilter, + "role": roleFilter, + "missing": booleanFilter, + "library_id": artistLibraryIdFilter, + }) + r.setSortMappings(map[string]string{ + "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')", + }) + return r +} + +func roleFilter(_ string, role any) Sqlizer { + if role, ok := role.(string); ok { + if _, ok := model.AllRoles[role]; ok { + return Expr("JSON_EXTRACT(library_artist.stats, '$." + role + ".m') IS NOT NULL") + } + } + return Eq{"1": 2} +} + +// artistLibraryIdFilter filters artists based on library access through the library_artist table +func artistLibraryIdFilter(_ string, value interface{}) 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(query SelectBuilder) SelectBuilder { + user := loggedUser(r.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(options ...model.QueryOptions) SelectBuilder { + // Stats Format: {"1": {"albumartist": {"m": 10, "a": 5, "s": 1024}, "artist": {...}}, "2": {...}} + query := r.newSelect(options...).Columns("artist.*", + "JSON_GROUP_OBJECT(library_artist.library_id, JSONB(library_artist.stats)) as library_stats_json") + + query = r.applyLibraryFilterToArtistQuery(query) + query = query.GroupBy("artist.id") + return r.withAnnotation(query, "artist.id") +} + +func (r *artistRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := r.newSelect() + query = r.applyLibraryFilterToArtistQuery(query) + query = r.withAnnotation(query, "artist.id") + return r.count(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(id string) (bool, error) { + // Create a query using the same library filtering logic as selectArtist() + query := r.newSelect().Columns("count(distinct artist.id) as exist").Where(Eq{"artist.id": id}) + query = r.applyLibraryFilterToArtistQuery(query) + + var res struct{ Exist int64 } + err := r.queryOne(query, &res) + return res.Exist > 0, err +} + +func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { + dba := &dbArtist{Artist: a} + dba.CreatedAt = P(time.Now()) + dba.UpdatedAt = dba.CreatedAt + _, err := r.put(dba.ID, dba, colsToUpdate...) + if err == nil && r.ms != nil { + r.ms.IndexArtist(a) + } + return err +} + +func (r *artistRepository) UpdateExternalInfo(a *model.Artist) error { + dba := &dbArtist{Artist: a} + _, err := r.put(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(id string) (*model.Artist, error) { + sel := r.selectArtist().Where(Eq{"artist.id": id}) + var dba dbArtists + if err := r.queryAll(sel, &dba); err != nil { + return nil, err + } + if len(dba) == 0 { + return nil, model.ErrNotFound + } + res := dba.toModels() + return &res[0], nil +} + +func (r *artistRepository) GetAll(options ...model.QueryOptions) (model.Artists, error) { + sel := r.selectArtist(options...) + var dba dbArtists + err := r.queryAll(sel, &dba) + if err != nil { + return nil, err + } + res := dba.toModels() + return res, err +} + +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) + for k, v := range r.indexGroups { + if strings.HasPrefix(name, strings.ToLower(k)) { + 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(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(options) + 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}) + } + slices.SortFunc(result, func(a, b model.ArtistIndex) int { + return cmp.Compare(a.ID, b.ID) + }) + return result, nil +} + +func (r *artistRepository) purgeEmpty() error { + del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)") + c, err := r.executeSQL(del) + if err != nil { + return fmt.Errorf("purging empty artists: %w", err) + } + if c > 0 { + log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c) + } + return nil +} + +// markMissing marks artists as missing if all their albums are missing. +func (r *artistRepository) markMissing() 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(q) + if err != nil { + return fmt.Errorf("marking missing artists: %w", err) + } + return nil +} + +// 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() (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(query) +} + +// 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(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(r.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(r.ctx, "RefreshStats: Refreshing touched artists.", "count", len(allTouchedArtistIDs)) + } + + if len(allTouchedArtistIDs) == 0 { + log.Debug(r.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(r.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(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 any library + cleanupSQL := Delete("library_artist").Where("stats = '{}'") + cleanupRows, err := r.executeSQL(cleanupSQL) + if err != nil { + log.Warn(r.ctx, "Failed to cleanup empty library_artist entries", "error", err) + } else if cleanupRows > 0 { + log.Debug(r.ctx, "Cleaned up empty library_artist entries", "rowsDeleted", cleanupRows) + } + + log.Debug(r.ctx, "RefreshStats: Successfully updated stats.", "totalArtistsProcessed", len(allTouchedArtistIDs), "totalDBRowsAffected", totalRowsAffected) + return totalRowsAffected, nil +} + +func (r *artistRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Artists, error) { + var res dbArtists + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectArtist(options...), q, []string{"mbz_artist_id"}, &res) + if err != nil { + return nil, fmt.Errorf("searching artist by MBID %q: %w", q, err) + } + } else { + if r.ms != nil { + ids, err := r.ms.Search("artists", q, offset, size) + if err == nil { + if len(ids) == 0 { + return model.Artists{}, nil + } + // Fetch matching artists from the database + // We need to fetch all fields to return complete objects + artists, err := r.GetAll(model.QueryOptions{Filters: Eq{"artist.id": ids}}) + if err != nil { + return nil, fmt.Errorf("fetching artists from meilisearch ids: %w", err) + } + // Reorder results to match Meilisearch order + idMap := make(map[string]model.Artist, len(artists)) + for _, a := range artists { + idMap[a.ID] = a + } + sorted := make(model.Artists, 0, len(artists)) + for _, id := range ids { + if a, ok := idMap[id]; ok { + sorted = append(sorted, a) + } + } + return sorted, nil + } + log.Warn(r.ctx, "Meilisearch search failed, falling back to SQL", "error", err) + } + // Natural order for artists is more performant by ID, due to GROUP BY clause in selectArtist + err := r.doSearch(r.selectArtist(options...), q, offset, size, &res, "artist.id", + "sum(json_extract(stats, '$.total.m')) desc", "name") + if err != nil { + return nil, fmt.Errorf("searching artist by query %q: %w", q, err) + } + } + return res.toModels(), nil +} + +func (r *artistRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *artistRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *artistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + role := "total" + if len(options) > 0 { + if v, ok := options[0].Filters["role"].(string); ok { + role = v + } + + if r.ms != nil { + if name, ok := options[0].Filters["name"].(string); ok && name != "" { + ids, err := r.ms.Search("artists", name, 0, 10000) + if err == nil { + log.Debug(r.ctx, "Meilisearch found matches", "count", len(ids), "query", name) + delete(options[0].Filters, "name") + options[0].Filters["id"] = ids + } else { + log.Warn(r.ctx, "Meilisearch search failed, falling back to SQL", "error", err) + } + } + } + } + r.sortMappings["song_count"] = "sum(stats->>'" + role + "'->>'m')" + r.sortMappings["album_count"] = "sum(stats->>'" + role + "'->>'a')" + r.sortMappings["size"] = "sum(stats->>'" + role + "'->>'s')" + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *artistRepository) EntityName() string { + return "artist" +} + +func (r *artistRepository) NewInstance() interface{} { + return &model.Artist{} +} + +var _ model.ArtistRepository = (*artistRepository)(nil) +var _ model.ResourceRepository = (*artistRepository)(nil) diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go new file mode 100644 index 0000000..b18a0c0 --- /dev/null +++ b/persistence/artist_repository_test.go @@ -0,0 +1,772 @@ +package persistence + +import ( + "context" + "encoding/json" + + "github.com/Masterminds/squirrel" + "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/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// 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() { + + 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)} + + 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)) + }, + 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)) + } + }, + // 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("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 + + BeforeEach(func() { + ctx := GinkgoT().Context() + ctx = request.WithUser(ctx, adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder(), nil).(*artistRepository) + }) + + Describe("Basic Operations", func() { + Describe("Count", func() { + It("returns the number of artists in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) + + 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 NOT in the DB", func() { + Expect(repo.Exists("666")).To(BeFalse()) + }) + }) + + Describe("Get", func() { + It("retrieves existing artist data", func() { + artist, err := repo.Get("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(&artistBeatles) + Expect(er).To(BeNil()) + + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + 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)) + + // Restore the original value + artistBeatles.SortArtistName = "" + er = repo.Put(&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(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + 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)) + }) + }) + + 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(&artistBeatles) + Expect(er).To(BeNil()) + + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + 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)) + + // Restore the original value + artistBeatles.SortArtistName = "" + er = repo.Put(&artistBeatles) + Expect(er).To(BeNil()) + }) + + It("returns the index when SortArtistName is empty", func() { + idx, err := repo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + Expect(idx[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)) + }) + }) + + 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(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(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(squirrel.Update("library_artist"). + Set("stats", "{}"). + Where(squirrel.Eq{"artist_id": artistBeatles.ID, "library_id": 1})) + _, _ = raw.executeSQL(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(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(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(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(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(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + + // With non-existent library ID, admin users see no content because no artists are associated with that library + idx, err = repo.GetIndex(false, []int{999}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(0)) // Even admin users need valid library associations + }) + }) + }) + + Describe("MBID and Text Search", func() { + var lib2 model.Library + var lr model.LibraryRepository + var restrictedUser model.User + var restrictedRepo model.ArtistRepository + var headlessRepo model.ArtistRepository + + BeforeEach(func() { + // Set up headless repo (no user context) + headlessRepo = NewArtistRepository(context.Background(), GetDBXBuilder(), nil) + + // Create library for testing access restrictions + lib2 = model.Library{ID: 0, Name: "Artist Test Library", Path: "/artist/test/lib"} + lr = NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + err := lr.Put(&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 + ctx := request.WithUser(GinkgoT().Context(), restrictedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder(), nil) + + // Ensure both test artists are associated with library 1 + err = lr.AddArtist(1, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + err = lr.AddArtist(1, artistKraftwerk.ID) + Expect(err).ToNot(HaveOccurred()) + + // Create the restricted user in the database + ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + err = ur.Put(&restrictedUser) + Expect(err).ToNot(HaveOccurred()) + err = ur.SetUserLibraries(restrictedUser.ID, []int{1}) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up library 2 + lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + _ = lr.(*libraryRepository).delete(squirrel.Eq{"id": lib2.ID}) + }) + + DescribeTable("MBID search behavior across different user types", + func(testRepo *model.ArtistRepository, shouldFind bool, testDesc string) { + // Create test artist with MBID + artistWithMBID := createTestArtistWithMBID("test-mbid-artist", "Test MBID Artist", "550e8400-e29b-41d4-a716-446655440010") + + err := createArtistWithLibrary(*testRepo, &artistWithMBID, 1) + Expect(err).ToNot(HaveOccurred()) + + // Test the search + results, err := (*testRepo).Search("550e8400-e29b-41d4-a716-446655440010", 0, 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(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": artistWithMBID.ID})) + } + }, + Entry("Admin user can find artist by MBID", &repo, true, "Admin should find MBID artist"), + Entry("Restricted user can find artist by MBID in accessible library", &restrictedRepo, true, "Restricted user should find MBID artist in accessible library"), + Entry("Headless process can find artist by MBID", &headlessRepo, 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(&inaccessibleArtist) + Expect(err).ToNot(HaveOccurred()) + + // Add to library 2 (not accessible to restricted user) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + Expect(err).ToNot(HaveOccurred()) + + // Restricted user should not find this artist + results, err := restrictedRepo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // But admin should find it + results, err = repo.Search("a74b1b7f-71a5-4011-9441-d0b5e4122711", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(1)) + + // Clean up + if raw, ok := repo.(*artistRepository); ok { + _, _ = raw.executeSQL(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("Beatles", 0, 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(&inaccessibleArtist) + Expect(err).ToNot(HaveOccurred()) + + // Add to library 2 (not accessible to restricted user) + err = lr.AddArtist(lib2.ID, inaccessibleArtist.ID) + Expect(err).ToNot(HaveOccurred()) + + // Restricted user should not find this artist + results, err := restrictedRepo.Search("Unique Search Name", 0, 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(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": inaccessibleArtist.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(lib2.ID, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + + // Headless processes should see all artists regardless of library + artists, err := headlessRepo.GetAll() + 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(lib2.ID, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + + // Filter by specific library + artists, err := headlessRepo.GetAll(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(lib2.ID, artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + + // Headless process should be able to get the artist + artist, err := headlessRepo.Get(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() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(2))) + + artists, err := repo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(2)) + + exists, err := repo.Exists(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(repo, &missingArtist, 1) + Expect(err).ToNot(HaveOccurred()) + + _, err = raw.executeSQL(squirrel.Update(raw.tableName).Set("missing", true).Where(squirrel.Eq{"id": missingArtist.ID})) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + _, _ = raw.executeSQL(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() + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(3)) // Including the missing artist + + // Search never returns missing artists (hardcoded behavior) + results, err := repo.Search("Missing Artist", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) + }) + + Context("Regular User Operations", func() { + var restrictedRepo model.ArtistRepository + 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 + ctx := GinkgoT().Context() + ctx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder(), nil) + }) + + Describe("Library Access Restrictions", func() { + It("CountAll returns 0 for users without library access", func() { + count, err := restrictedRepo.CountAll() + 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() + 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(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + + exists, err = restrictedRepo.Exists(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(artistBeatles.ID) + Expect(err).To(Equal(model.ErrNotFound)) + + _, err = restrictedRepo.Get(artistKraftwerk.ID) + Expect(err).To(Equal(model.ErrNotFound)) + }) + + It("Search returns empty results for users without library access", func() { + results, err := restrictedRepo.Search("Beatles", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + results, err = restrictedRepo.Search("Kraftwerk", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + + It("GetIndex returns empty index for users without library access", func() { + idx, err := restrictedRepo.GetIndex(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(0)) + }) + }) + + Context("when user gains library access", func() { + BeforeEach(func() { + ctx := GinkgoT().Context() + // Give the user access to library 1 + ur := NewUserRepository(request.WithUser(ctx, adminUser), GetDBXBuilder()) + + // First create the user if not exists + err := ur.Put(&unauthorizedUser) + Expect(err).ToNot(HaveOccurred()) + + // Then add library access + err = ur.SetUserLibraries(unauthorizedUser.ID, []int{1}) + Expect(err).ToNot(HaveOccurred()) + + // Update the user object with the libraries to simulate middleware behavior + libraries, err := ur.GetUserLibraries(unauthorizedUser.ID) + Expect(err).ToNot(HaveOccurred()) + unauthorizedUser.Libraries = libraries + + // Recreate repository context with updated user + ctx = request.WithUser(ctx, unauthorizedUser) + restrictedRepo = NewArtistRepository(ctx, GetDBXBuilder(), nil) + }) + + AfterEach(func() { + // Clean up: remove the user's library access + ur := NewUserRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + _ = ur.SetUserLibraries(unauthorizedUser.ID, []int{}) + }) + + It("CountAll returns correct count after gaining access", func() { + count, err := restrictedRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(2))) // Beatles and Kraftwerk + }) + + It("GetAll returns artists after gaining access", func() { + artists, err := restrictedRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(artists).To(HaveLen(2)) + + var names []string + for _, artist := range artists { + names = append(names, artist.Name) + } + Expect(names).To(ContainElements("The Beatles", "Kraftwerk")) + }) + + It("Exists returns true for accessible artists", func() { + exists, err := restrictedRepo.Exists(artistBeatles.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + exists, err = restrictedRepo.Exists(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(false, []int{1}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(2)) + + // With non-existent library ID, should see nothing (non-admin user) + idx, err = restrictedRepo.GetIndex(false, []int{999}) + Expect(err).ToNot(HaveOccurred()) + Expect(idx).To(HaveLen(0)) + }) + }) + }) +}) + +// 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(repo model.ArtistRepository, artist *model.Artist, libraryID int) error { + err := repo.Put(artist) + if err != nil { + return err + } + + // Add the artist to the specified library + lr := NewLibraryRepository(request.WithUser(GinkgoT().Context(), adminUser), GetDBXBuilder()) + return lr.AddArtist(libraryID, artist.ID) +} diff --git a/persistence/collation_test.go b/persistence/collation_test.go new file mode 100644 index 0000000..7e11447 --- /dev/null +++ b/persistence/collation_test.go @@ -0,0 +1,122 @@ +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("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.sort_title", "media_file", "coalesce(nullif(sort_title,''),order_title) collate nocase"), + Entry("media_file.sort_album_name", "media_file", "coalesce(nullif(sort_album_name,''),order_album_name) collate nocase"), + Entry("media_file.sort_artist_name", "media_file", "coalesce(nullif(sort_artist_name,''),order_artist_name) collate nocase"), + Entry("media_file.path", "media_file", "path 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/export_test.go b/persistence/export_test.go new file mode 100644 index 0000000..402baf2 --- /dev/null +++ b/persistence/export_test.go @@ -0,0 +1,4 @@ +package persistence + +// Definitions for testing private methods +var GetIndexKey = (*artistRepository).getIndexKey diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go new file mode 100644 index 0000000..a586746 --- /dev/null +++ b/persistence/folder_repository.go @@ -0,0 +1,216 @@ +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "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:"-"` +} + +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(ctx context.Context, db dbx.Builder) model.FolderRepository { + r := &folderRepository{} + r.ctx = ctx + r.db = db + r.tableName = "folder" + return r +} + +func (r folderRepository) selectFolder(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(options...).Columns("folder.*", "library.path as library_path"). + Join("library on library.id = folder.library_id") + return r.applyLibraryFilter(sql) +} + +func (r folderRepository) Get(id string) (*model.Folder, error) { + sq := r.selectFolder().Where(Eq{"folder.id": id}) + var res dbFolder + err := r.queryOne(sq, &res) + return res.Folder, err +} + +func (r folderRepository) GetByPath(lib model.Library, path string) (*model.Folder, error) { + id := model.NewFolder(lib, path).ID + return r.Get(id) +} + +func (r folderRepository) GetAll(opt ...model.QueryOptions) ([]model.Folder, error) { + sq := r.selectFolder(opt...) + var res dbFolders + err := r.queryAll(sq, &res) + return res.toModels(), err +} + +func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) { + query := r.newSelect(opt...).Columns("count(*)") + query = r.applyLibraryFilter(query) + return r.count(query) +} + +func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) { + where := And{ + Eq{"library_id": lib.ID}, + Eq{"missing": false}, + } + + // If specific paths are requested, include those folders and all their descendants + if len(targetPaths) > 0 { + // 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 { + if targetPath == "" || targetPath == "." { + // Root path - include everything in this library + pathConditions = Or{} + folderIDs = nil + break + } + // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes. + cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator)) + cleanPath = filepath.Clean(cleanPath) + + // 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) + } + } + + sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where) + var res []struct { + ID string + UpdatedAt time.Time + Hash string + } + err := r.queryAll(sq, &res) + if err != nil { + return nil, err + } + m := make(map[string]model.FolderUpdateInfo, len(res)) + for _, f := range res { + m[f.ID] = model.FolderUpdateInfo{UpdatedAt: f.UpdatedAt, Hash: f.Hash} + } + return m, nil +} + +func (r folderRepository) Put(f *model.Folder) error { + dbf := dbFolder{Folder: f} + _, err := r.put(dbf.ID, &dbf) + return err +} + +func (r folderRepository) MarkMissing(missing bool, ids ...string) error { + log.Debug(r.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(sq) + if err != nil { + return err + } + } + return nil +} + +func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { + query := r.selectFolder().Where(And{ + Eq{"missing": false}, + Gt{"num_playlists": 0}, + ConcatExpr("folder.updated_at > library.last_scan_at"), + }) + cursor, err := queryWithStableResults[dbFolder](r.sqlRepository, query) + if err != nil { + return nil, err + } + return func(yield func(model.Folder, error) bool) { + for f, err := range cursor { + if !yield(*f.Folder, err) || err != nil { + return + } + } + }, nil +} + +func (r folderRepository) purgeEmpty(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(sq) + if err != nil { + return fmt.Errorf("purging empty folders: %w", err) + } + if c > 0 { + log.Debug(r.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 new file mode 100644 index 0000000..6c24741 --- /dev/null +++ b/persistence/folder_repository_test.go @@ -0,0 +1,213 @@ +package persistence + +import ( + "context" + "fmt" + + "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("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(ctx, conn) + + // Use existing library ID 1 from test fixtures + libRepo := NewLibraryRepository(ctx, conn) + lib, err := libRepo.Get(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(&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("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(folder1) + Expect(err).ToNot(HaveOccurred()) + err = repo.Put(folder2) + Expect(err).ToNot(HaveOccurred()) + + otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder") + err = repo.Put(otherFolder) + Expect(err).ToNot(HaveOccurred()) + + // Query all folders (no target paths) - should only return folders from testLib + results, err := repo.GetFolderUpdateInfo(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(folder1) + Expect(err).ToNot(HaveOccurred()) + err = repo.Put(folder2) + Expect(err).ToNot(HaveOccurred()) + err = repo.Put(folder3) + Expect(err).ToNot(HaveOccurred()) + + // Query specific paths + results, err := repo.GetFolderUpdateInfo(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(parent)).To(Succeed()) + Expect(repo.Put(child1)).To(Succeed()) + Expect(repo.Put(child2)).To(Succeed()) + + // Query the parent folder - should return parent and all children + results, err := repo.GetFolderUpdateInfo(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(parent)).To(Succeed()) + Expect(repo.Put(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(otherParent)).To(Succeed()) + Expect(repo.Put(otherChild)).To(Succeed()) + + // Query should only return folders from testLib + results, err := repo.GetFolderUpdateInfo(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(parent)).To(Succeed()) + Expect(repo.Put(child1)).To(Succeed()) + Expect(repo.Put(child2)).To(Succeed()) + + // Query parent - should only return parent and non-missing child + results, err := repo.GetFolderUpdateInfo(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(existingParent)).To(Succeed()) + Expect(repo.Put(existingChild)).To(Succeed()) + + // Query both existing and non-existing paths + results, err := repo.GetFolderUpdateInfo(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(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(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(folder) + Expect(err).ToNot(HaveOccurred()) + + results, err := repo.GetFolderUpdateInfo(testLib, "TestMissing/Folder") + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + }) + }) + }) +}) diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go new file mode 100644 index 0000000..5857350 --- /dev/null +++ b/persistence/genre_repository.go @@ -0,0 +1,52 @@ +package persistence + +import ( + "context" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type genreRepository struct { + *baseTagRepository +} + +func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { + genreFilter := model.TagGenre + return &genreRepository{ + baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter), + } +} + +func (r *genreRepository) selectGenre(opt ...model.QueryOptions) SelectBuilder { + return r.newSelect(opt...).Columns("tag.tag_value as name") +} + +func (r *genreRepository) GetAll(opt ...model.QueryOptions) (model.Genres, error) { + sq := r.selectGenre(opt...) + res := model.Genres{} + err := r.queryAll(sq, &res) + return res, err +} + +// Override ResourceRepository methods to return Genre objects instead of Tag objects + +func (r *genreRepository) Read(id string) (interface{}, error) { + sel := r.selectGenre().Where(Eq{"tag.id": id}) + var res model.Genre + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *genreRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *genreRepository) NewInstance() interface{} { + return &model.Genre{} +} + +var _ model.GenreRepository = (*genreRepository)(nil) +var _ model.ResourceRepository = (*genreRepository)(nil) diff --git a/persistence/genre_repository_test.go b/persistence/genre_repository_test.go new file mode 100644 index 0000000..67e84ce --- /dev/null +++ b/persistence/genre_repository_test.go @@ -0,0 +1,329 @@ +package persistence + +import ( + "context" + "slices" + "strings" + + "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "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("GenreRepository", func() { + var repo model.GenreRepository + var restRepo model.ResourceRepository + 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(ctx, GetDBXBuilder()) + repo = genreRepo + restRepo = genreRepo.(model.ResourceRepository) + tagRepo = NewTagRepository(ctx, 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(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()) + }) + + Describe("GetAll", func() { + It("should return all genres", func() { + genres, err := repo.GetAll() + 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(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() + 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(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(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(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("Count", func() { + It("should return correct count of genres", func() { + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(12))) // We have 12 genre tags + }) + + 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()) + + count, err := restRepo.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeZero()) + }) + + 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(1, nonGenreTag) + Expect(err).ToNot(HaveOccurred()) + + count, err := restRepo.Count() + 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]interface{}{"name": "%rock%"}, + } + count, err := restRepo.Count(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") + result, err := restRepo.Read(genreID) + Expect(err).ToNot(HaveOccurred()) + genre := result.(*model.Genre) + Expect(genre.ID).To(Equal(genreID)) + Expect(genre.Name).To(Equal("rock")) + }) + + It("should return error for non-existent genre", func() { + _, err := restRepo.Read("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(moodID) + Expect(err).To(HaveOccurred()) // Should not find it as a genre + }) + }) + + Describe("ReadAll", func() { + It("should return all genres through ReadAll", func() { + result, err := restRepo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + genres := result.(model.Genres) + 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() + 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 model.ResourceRepository + + BeforeEach(func() { + // Create a repository with no user context (headless) + headlessGenreRepo := NewGenreRepository(context.Background(), GetDBXBuilder()) + headlessRepo = headlessGenreRepo + headlessRestRepo = headlessGenreRepo.(model.ResourceRepository) + + // 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(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() + 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() + 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 + genres, err := headlessRestRepo.ReadAll(rest.QueryOptions{ + Filters: map[string]interface{}{"library_id": 2}, + }) + Expect(err).ToNot(HaveOccurred()) + + genreList := genres.(model.Genres) + // 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() + Expect(err).ToNot(HaveOccurred()) + Expect(genres).ToNot(BeEmpty()) + + // Headless process should be able to get the genre + genre, err := headlessRestRepo.Read(genres[0].ID) + Expect(err).ToNot(HaveOccurred()) + Expect(genre).ToNot(BeNil()) + }) + }) + }) + + Describe("EntityName", func() { + It("should return correct entity name", func() { + name := restRepo.EntityName() + Expect(name).To(Equal("tag")) // Genre repository uses tag table + }) + }) + + Describe("NewInstance", func() { + It("should return new genre instance", func() { + instance := restRepo.NewInstance() + Expect(instance).To(BeAssignableToTypeOf(&model.Genre{})) + }) + }) +}) diff --git a/persistence/helpers.go b/persistence/helpers.go new file mode 100644 index 0000000..73815ae --- /dev/null +++ b/persistence/helpers.go @@ -0,0 +1,92 @@ +package persistence + +import ( + "database/sql/driver" + "fmt" + "regexp" + "strings" + "time" + + "github.com/Masterminds/squirrel" + "github.com/fatih/structs" +) + +type PostMapper interface { + PostMapArgs(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 r, ok := rec.(PostMapper); ok { + err := r.PostMapArgs(m) + if err != nil { + return nil, err + } + } + return m, nil +} + +var matchFirstCap = regexp.MustCompile("(.)([A-Z][a-z]+)") +var matchAllCap = regexp.MustCompile("([a-z0-9])([A-Z])") + +func toSnakeCase(str string) string { + snake := matchFirstCap.ReplaceAllString(str, "${1}_${2}") + snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") + 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 { + return existsCond{subTable: subTable, cond: cond, not: false} +} + +func NotExists(subTable string, cond squirrel.Sqlizer) existsCond { + return existsCond{subTable: subTable, cond: cond, not: true} +} + +type existsCond struct { + subTable string + cond squirrel.Sqlizer + not bool +} + +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 { + sql = "not " + sql + } + return sql, args, err +} + +var sortOrderRegex = regexp.MustCompile(`order_([a-z_]+)`) + +// Convert the order_* columns to an expression using sort_* columns. Example: +// sort_album_name -> (coalesce(nullif(sort_album_name,”),order_album_name) collate nocase) +// It finds order column names anywhere in the substring +func mapSortOrder(tableName, order string) string { + order = strings.ToLower(order) + repl := fmt.Sprintf("(coalesce(nullif(%[1]s.sort_$1,''),%[1]s.order_$1) collate nocase)", tableName) + return sortOrderRegex.ReplaceAllString(order, repl) +} diff --git a/persistence/helpers_test.go b/persistence/helpers_test.go new file mode 100644 index 0000000..85893ef --- /dev/null +++ b/persistence/helpers_test.go @@ -0,0 +1,106 @@ +package persistence + +import ( + "time" + + "github.com/Masterminds/squirrel" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Helpers", func() { + Describe("toSnakeCase", func() { + It("converts camelCase", func() { + Expect(toSnakeCase("camelCase")).To(Equal("camel_case")) + }) + It("converts PascalCase", func() { + Expect(toSnakeCase("PascalCase")).To(Equal("pascal_case")) + }) + It("converts ALLCAPS", func() { + Expect(toSnakeCase("ALLCAPS")).To(Equal("allcaps")) + }) + It("does not converts snake_case", 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() { + type Embed struct{} + type Model struct { + Embed `structs:"-"` + ID string `structs:"id" json:"id"` + AlbumId string `structs:"album_id" json:"albumId"` + PlayCount int `structs:"play_count" json:"playCount"` + UpdatedAt *time.Time `structs:"updated_at"` + CreatedAt time.Time `structs:"created_at"` + } + + 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) + 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")), + )) + }) + }) + + Describe("Exists", func() { + It("constructs the correct EXISTS query", func() { + 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)) + Expect(err).To(BeNil()) + }) + }) + + Describe("NotExists", func() { + It("constructs the correct NOT EXISTS query", func() { + 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() { + It("does not change the sort string if there are no order columns", func() { + sort := "album_name asc" + mapped := mapSortOrder("album", sort) + Expect(mapped).To(Equal(sort)) + }) + It("changes order columns to sort expression", func() { + sort := "ORDER_ALBUM_NAME asc" + mapped := mapSortOrder("album", sort) + Expect(mapped).To(Equal(`(coalesce(nullif(album.sort_album_name,''),album.order_album_name)` + + ` collate nocase) asc`)) + }) + It("changes multiple order columns to sort expressions", func() { + sort := "compilation, order_title asc, order_album_artist_name desc, year desc" + mapped := mapSortOrder("album", sort) + Expect(mapped).To(Equal(`compilation, (coalesce(nullif(album.sort_title,''),album.order_title) collate nocase) asc,` + + ` (coalesce(nullif(album.sort_album_artist_name,''),album.order_album_artist_name) collate nocase) desc, year desc`)) + }) + }) +}) diff --git a/persistence/library_repository.go b/persistence/library_repository.go new file mode 100644 index 0000000..9349f3c --- /dev/null +++ b/persistence/library_repository.go @@ -0,0 +1,347 @@ +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(ctx context.Context, db dbx.Builder) model.LibraryRepository { + r := &libraryRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.Library{}, nil) + return r +} + +func (r *libraryRepository) Get(id int) (*model.Library, error) { + sq := r.newSelect().Columns("*").Where(Eq{"id": id}) + var res model.Library + err := r.queryOne(sq, &res) + return &res, err +} + +func (r *libraryRepository) GetPath(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() + if err != nil { + log.Error(r.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(l *model.Library) error { + if l.ID == model.DefaultLibraryID { + currentLib, err := r.Get(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 := map[string]any{ + "name": l.Name, + "path": l.Path, + "remote_path": l.RemotePath, + "default_new_users": l.DefaultNewUsers, + "updated_at": l.UpdatedAt, + } + sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID}) + rowsAffected, updateErr := r.executeSQL(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(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() error { + sq := Update(r.tableName).Set("path", conf.Server.MusicFolder). + Set("updated_at", time.Now()). + Where(Eq{"id": model.DefaultLibraryID}) + _, err := r.executeSQL(sq) + if err != nil { + libLock.Lock() + defer libLock.Unlock() + libCache[model.DefaultLibraryID] = conf.Server.MusicFolder + } + return err +} + +func (r *libraryRepository) AddArtist(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(sq) + if err != nil { + return err + } + return nil +} + +func (r *libraryRepository) ScanBegin(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(sq) + return err +} + +func (r *libraryRepository) ScanEnd(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(sq) + if err != nil { + return err + } + // https://www.sqlite.org/pragma.html#pragma_optimize + // Use mask 0x10000 to check table sizes without running ANALYZE + // Running ANALYZE can cause query planner issues with expression-based collation indexes + if conf.Server.DevOptimizeDB { + _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;")) + } + return err +} + +func (r *libraryRepository) ScanInProgress() (bool, error) { + query := r.newSelect().Where(NotEq{"last_scan_started_at": time.Time{}}) + count, err := r.count(query) + return count > 0, err +} + +func (r *libraryRepository) RefreshStats(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(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": false}), &songsRes) + }, + func() error { + return r.queryOne(Select("count(*) as count").From("album").Where(Eq{"library_id": id, "missing": false}), &albumsRes) + }, + func() error { + return r.queryOne(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(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(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(Select("count(*) as count").From("media_file").Where(Eq{"library_id": id, "missing": true}), &missingRes) + }, + func() error { + return r.queryOne(Select("ifnull(sum(size),0) as sum").From("album").Where(Eq{"library_id": id, "missing": false}), &sizeRes) + }, + func() error { + return r.queryOne(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(sq) + return err +} + +func (r *libraryRepository) Delete(id int) error { + if !loggedUser(r.ctx).IsAdmin { + return model.ErrNotAuthorized + } + if id == 1 { + return fmt.Errorf("%w: library with ID 1 cannot be deleted", model.ErrValidation) + } + + err := r.delete(Eq{"id": id}) + if err != nil { + return err + } + + // Clear cache entry for this library only if DB operation was successful + libLock.Lock() + defer libLock.Unlock() + delete(libCache, id) + + return nil +} + +func (r *libraryRepository) GetAll(ops ...model.QueryOptions) (model.Libraries, error) { + sq := r.newSelect(ops...).Columns("*") + res := model.Libraries{} + err := r.queryAll(sq, &res) + return res, err +} + +func (r *libraryRepository) CountAll(ops ...model.QueryOptions) (int64, error) { + sq := r.newSelect(ops...) + return r.count(sq) +} + +// User-library association methods + +func (r *libraryRepository) GetUsersWithLibraryAccess(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(sel, &res) + return res, err +} + +// REST interface methods + +func (r *libraryRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *libraryRepository) Read(id string) (interface{}, error) { + idInt, err := strconv.Atoi(id) + if err != nil { + log.Trace(r.ctx, "invalid library id: %s", id, err) + return nil, rest.ErrNotFound + } + return r.Get(idInt) +} + +func (r *libraryRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *libraryRepository) EntityName() string { + return "library" +} + +func (r *libraryRepository) NewInstance() interface{} { + return &model.Library{} +} + +func (r *libraryRepository) Save(entity interface{}) (string, error) { + lib := entity.(*model.Library) + lib.ID = 0 // Reset ID to ensure we create a new library + err := r.Put(lib) + if err != nil { + return "", err + } + return strconv.Itoa(lib.ID), nil +} + +func (r *libraryRepository) Update(id string, entity interface{}, cols ...string) error { + lib := entity.(*model.Library) + idInt, err := strconv.Atoi(id) + if err != nil { + return fmt.Errorf("invalid library ID: %s", id) + } + + lib.ID = idInt + return r.Put(lib) +} + +var _ model.LibraryRepository = (*libraryRepository)(nil) +var _ rest.Repository = (*libraryRepository)(nil) diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go new file mode 100644 index 0000000..3e3972b --- /dev/null +++ b/persistence/library_repository_test.go @@ -0,0 +1,203 @@ +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" + "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(ctx, 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(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(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedLib.Name).To(Equal("Test Library")) + Expect(savedLib.Path).To(Equal("/music/test")) + }) + }) + + 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(lib) + Expect(err).ToNot(HaveOccurred()) + + originalID := lib.ID + originalCreatedAt := lib.CreatedAt + + // Now update it + lib.Name = "Updated Library" + lib.Path = "/music/updated" + err = repo.Put(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(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(999) + Expect(err).To(HaveOccurred()) + + // Put should insert it + err = repo.Put(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(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")) + }) + }) + }) + + It("refreshes stats", func() { + libBefore, err := repo.Get(1) + Expect(err).ToNot(HaveOccurred()) + Expect(repo.RefreshStats(1)).To(Succeed()) + libAfter, err := repo.Get(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(lib) + Expect(err).ToNot(HaveOccurred()) + }) + + DescribeTable("ScanBegin", + func(fullScan bool, expectedFullScanInProgress bool) { + err := repo.ScanBegin(lib.ID, fullScan) + Expect(err).ToNot(HaveOccurred()) + + updatedLib, err := repo.Get(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(lib.ID, true) + Expect(err).ToNot(HaveOccurred()) + }) + + It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() { + err := repo.ScanEnd(lib.ID) + Expect(err).ToNot(HaveOccurred()) + + updatedLib, err := repo.Get(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(lib.ID) + Expect(err).ToNot(HaveOccurred()) + + err = repo.ScanEnd(lib.ID) + Expect(err).ToNot(HaveOccurred()) + + libAfter, err := repo.Get(lib.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt)) + }) + }) + }) +}) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go new file mode 100644 index 0000000..fbe2c02 --- /dev/null +++ b/persistence/mediafile_repository.go @@ -0,0 +1,477 @@ +package persistence + +import ( + "context" + "fmt" + "slices" + "strconv" + "strings" + "sync" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "github.com/pocketbase/dbx" +) + +type mediaFileRepository struct { + sqlRepository + ms *MeilisearchService +} + +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:"-"` +} + +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} + fullText = append(fullText, m.MediaFile.Participants.AllNames()...) + args["full_text"] = formatFullText(fullText...) + 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(ctx context.Context, db dbx.Builder, ms *MeilisearchService) model.MediaFileRepository { + r := &mediaFileRepository{} + r.ctx = ctx + r.db = db + r.ms = ms + 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, title", + "random": "random", + "created_at": "media_file.created_at", + "recently_added": mediaFileRecentlyAddedSort(), + "starred_at": "starred, starred_at", + "rated_at": "rating, rated_at", + }) + 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": booleanFilter, + "genre_id": tagIDFilter, + "missing": booleanFilter, + "artists_id": artistFilter, + "library_id": libraryIdFilter, + } + // Add all album tags as filters + for tag := range model.TagMappings() { + if _, exists := filters[string(tag)]; !exists { + filters[string(tag)] = tagIDFilter + } + } + return filters +}) + +func mediaFileRecentlyAddedSort() string { + if conf.Server.RecentlyAddedByModTime { + return "media_file.updated_at" + } + return "media_file.created_at" +} + +func (r *mediaFileRepository) CountAll(options ...model.QueryOptions) (int64, error) { + query := r.newSelect() + query = r.withAnnotation(query, "media_file.id") + query = r.applyLibraryFilter(query) + return r.count(query, options...) +} + +func (r *mediaFileRepository) Exists(id string) (bool, error) { + return r.exists(Eq{"media_file.id": id}) +} + +func (r *mediaFileRepository) Put(m *model.MediaFile) error { + m.CreatedAt = time.Now() + id, err := r.putByMatch(Eq{"path": m.Path, "library_id": m.LibraryID}, m.ID, &dbMediaFile{MediaFile: m}) + if err != nil { + return err + } + m.ID = id + err = r.updateParticipants(m.ID, m.Participants) + if err != nil { + return err + } + if r.ms != nil { + r.ms.IndexMediaFile(m) + } + return nil +} + +func (r *mediaFileRepository) selectMediaFile(options ...model.QueryOptions) SelectBuilder { + sql := r.newSelect(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(sql, "media_file.id") + sql = r.withBookmark(sql, "media_file.id") + return r.applyLibraryFilter(sql) +} + +func (r *mediaFileRepository) Get(id string) (*model.MediaFile, error) { + res, err := r.GetAll(model.QueryOptions{Filters: Eq{"media_file.id": id}}) + if err != nil { + return nil, err + } + if len(res) == 0 { + return nil, model.ErrNotFound + } + return &res[0], nil +} + +func (r *mediaFileRepository) GetWithParticipants(id string) (*model.MediaFile, error) { + m, err := r.Get(id) + if err != nil { + return nil, err + } + m.Participants, err = r.getParticipants(m) + return m, err +} + +func (r *mediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) { + sq := r.selectMediaFile(options...) + var res dbMediaFiles + err := r.queryAll(sq, &res, options...) + if err != nil { + return nil, err + } + return res.toModels(), nil +} + +func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.MediaFileCursor, error) { + sq := r.selectMediaFile(options...) + cursor, err := queryWithStableResults[dbMediaFile](r.sqlRepository, sq) + if err != nil { + return nil, err + } + return func(yield func(model.MediaFile, error) bool) { + for m, err := range cursor { + if m.MediaFile == nil { + yield(model.MediaFile{}, fmt.Errorf("unexpected nil mediafile: %v", m)) + return + } + if !yield(*m.MediaFile, err) || err != nil { + return + } + } + }, nil +} + +// 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(paths []string) (model.MediaFiles, error) { + query := Or{} + + for _, path := range paths { + parts := strings.SplitN(path, ":", 2) + if len(parts) == 2 { + // Library-qualified path: "libraryID:path" + libraryID, err := strconv.Atoi(parts[0]) + if err != nil { + // Invalid format, skip + continue + } + relativePath := parts[1] + query = append(query, And{ + Eq{"path collate nocase": relativePath}, + Eq{"library_id": libraryID}, + }) + } else { + // Unqualified path: search across all libraries + query = append(query, Eq{"path collate nocase": path}) + } + } + + if len(query) == 0 { + return model.MediaFiles{}, nil + } + + sel := r.newSelect().Columns("*").Where(query) + var res dbMediaFiles + if err := r.queryAll(sel, &res); err != nil { + return nil, err + } + + return res.toModels(), nil +} + +func (r *mediaFileRepository) Delete(id string) error { + err := r.delete(Eq{"id": id}) + if err == nil && r.ms != nil { + r.ms.DeleteMediaFile(id) + } + return err +} + +func (r *mediaFileRepository) DeleteAllMissing() (int64, error) { + user := loggedUser(r.ctx) + if !user.IsAdmin { + return 0, rest.ErrPermissionDenied + } + del := Delete(r.tableName).Where(Eq{"missing": true}) + var ids []string + if r.ms != nil { + _ = r.db.Select("id").From(r.tableName).Where(dbx.HashExp{"missing": true}).Column(&ids) + } + c, err := r.executeSQL(del) + if err == nil && r.ms != nil && len(ids) > 0 { + r.ms.DeleteMediaFiles(ids) + } + return c, err +} + +func (r *mediaFileRepository) DeleteMissing(ids []string) error { + user := loggedUser(r.ctx) + if !user.IsAdmin { + return rest.ErrPermissionDenied + } + err := r.delete( + And{ + Eq{"missing": true}, + Eq{"id": ids}, + }, + ) + if err == nil && r.ms != nil { + r.ms.DeleteMediaFiles(ids) + } + return err +} + +func (r *mediaFileRepository) MarkMissing(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(upd) + if err != nil || c == 0 { + log.Error(r.ctx, "Error setting mediafile missing flag", "ids", chunk, err) + return err + } + log.Debug(r.ctx, "Marked missing mediafiles", "total", c, "ids", chunk) + } + return nil +} + +func (r *mediaFileRepository) MarkMissingByFolder(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(upd) + if err != nil { + log.Error(r.ctx, "Error setting mediafile missing flag", "folderIDs", chunk, err) + return err + } + log.Debug(r.ctx, "Marked missing mediafiles from missing folders", "total", c, "folders", chunk) + } + return nil +} + +// 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(libId int) (model.MediaFileCursor, error) { + subQ := r.newSelect().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().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](r.sqlRepository, sel) + if err != nil { + return nil, err + } + return func(yield func(model.MediaFile, error) bool) { + for m, err := range cursor { + if !yield(*m.MediaFile, err) || err != nil { + return + } + } + }, nil +} + +// FindRecentFilesByMBZTrackID finds recently added files by MusicBrainz Track ID in other libraries +func (r *mediaFileRepository) FindRecentFilesByMBZTrackID(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.selectMediaFile().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(sel, &res) + if err != nil { + return nil, err + } + return res.toModels(), nil +} + +// FindRecentFilesByProperties finds recently added files by intrinsic properties in other libraries +func (r *mediaFileRepository) FindRecentFilesByProperties(missing model.MediaFile, since time.Time) (model.MediaFiles, error) { + sel := r.selectMediaFile().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(sel, &res) + if err != nil { + return nil, err + } + return res.toModels(), nil +} + +func (r *mediaFileRepository) Search(q string, offset int, size int, options ...model.QueryOptions) (model.MediaFiles, error) { + var res dbMediaFiles + if uuid.Validate(q) == nil { + err := r.searchByMBID(r.selectMediaFile(options...), q, []string{"mbz_recording_id", "mbz_release_track_id"}, &res) + if err != nil { + return nil, fmt.Errorf("searching media_file by MBID %q: %w", q, err) + } + } else { + if r.ms != nil { + ids, err := r.ms.Search("mediafiles", q, offset, size) + if err == nil { + if len(ids) == 0 { + return model.MediaFiles{}, nil + } + // Fetch matching media files from the database + // We need to fetch all fields to return complete objects + mfs, err := r.GetAll(model.QueryOptions{Filters: Eq{"media_file.id": ids}}) + if err != nil { + return nil, fmt.Errorf("fetching media_files from meilisearch ids: %w", err) + } + // Reorder results to match Meilisearch order + idMap := make(map[string]model.MediaFile, len(mfs)) + for _, mf := range mfs { + idMap[mf.ID] = mf + } + sorted := make(model.MediaFiles, 0, len(mfs)) + for _, id := range ids { + if mf, ok := idMap[id]; ok { + sorted = append(sorted, mf) + } + } + return sorted, nil + } + log.Warn(r.ctx, "Meilisearch search failed, falling back to SQL", "error", err) + } + err := r.doSearch(r.selectMediaFile(options...), q, offset, size, &res, "media_file.rowid", "title") + if err != nil { + return nil, fmt.Errorf("searching media_file by query %q: %w", q, err) + } + } + return res.toModels(), nil +} + +func (r *mediaFileRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *mediaFileRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *mediaFileRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + if len(options) > 0 && r.ms != nil { + if title, ok := options[0].Filters["title"].(string); ok && title != "" { + ids, err := r.ms.Search("mediafiles", title, 0, 10000) + if err == nil { + log.Debug(r.ctx, "Meilisearch found matches", "count", len(ids), "query", title) + delete(options[0].Filters, "title") + options[0].Filters["id"] = ids + } else { + log.Warn(r.ctx, "Meilisearch search failed, falling back to SQL", "error", err) + } + } + } + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *mediaFileRepository) EntityName() string { + return "mediafile" +} + +func (r *mediaFileRepository) NewInstance() interface{} { + return &model.MediaFile{} +} + +var _ model.MediaFileRepository = (*mediaFileRepository)(nil) +var _ model.ResourceRepository = (*mediaFileRepository)(nil) diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go new file mode 100644 index 0000000..024a659 --- /dev/null +++ b/persistence/mediafile_repository_test.go @@ -0,0 +1,413 @@ +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/id" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("MediaRepository", func() { + var mr model.MediaFileRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + mr = NewMediaFileRepository(ctx, GetDBXBuilder(), nil) + }) + + It("gets mediafile from the DB", func() { + actual, err := mr.Get("1004") + Expect(err).ToNot(HaveOccurred()) + actual.CreatedAt = time.Time{} + Expect(actual).To(Equal(&songAntenna)) + }) + + It("returns ErrNotFound", func() { + _, err := mr.Get("56") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("counts the number of mediafiles in the DB", func() { + Expect(mr.CountAll()).To(Equal(int64(10))) + }) + + 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(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")) + } + }) + + It("checks existence of mediafiles in the DB", func() { + Expect(mr.Exists(songAntenna.ID)).To(BeTrue()) + Expect(mr.Exists("666")).To(BeFalse()) + }) + + It("delete tracks by id", func() { + newID := id.NewRandom() + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: newID})).To(Succeed()) + + Expect(mr.Delete(newID)).To(Succeed()) + + _, err := mr.Get(newID) + 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(&new1)).To(Succeed()) + Expect(mr.Put(&new2)).To(Succeed()) + Expect(mr.MarkMissing(true, &new1, &new2)).To(Succeed()) + + adminCtx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", IsAdmin: true}) + adminRepo := NewMediaFileRepository(adminCtx, GetDBXBuilder(), nil) + + // Ensure the files are marked as missing and we have 2 of them + count, err := adminRepo.CountAll(model.QueryOptions{Filters: squirrel.Eq{"missing": true}}) + Expect(count).To(BeNumerically("==", 2)) + Expect(err).ToNot(HaveOccurred()) + + count, err = adminRepo.DeleteAllMissing() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + + _, err = mr.Get(new1.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = mr.Get(new2.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + Context("Annotations", func() { + It("increments play count when the tracks does not have annotations", func() { + id := "incplay.firsttime" + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + playDate := time.Now() + 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))) + }) + + It("preserves play date if and only if provided date is older", func() { + id := "incplay.playdate" + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + playDate := time.Now() + 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(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(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))) + }) + + It("increments play count on newly starred items", func() { + id := "star.incplay" + Expect(mr.Put(&model.MediaFile{LibraryID: 1, ID: id})).To(BeNil()) + Expect(mr.SetStar(true, id)).To(BeNil()) + playDate := time.Now() + 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))) + }) + }) + + 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(&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]interface{}{ + "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]interface{}{ + "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]interface{}{ + "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(mf.ID) + } + }) + + When("RecentlyAddedByModTime is false", func() { + var testRepo model.MediaFileRepository + + BeforeEach(func() { + conf.Server.RecentlyAddedByModTime = false + // Create repository AFTER setting config + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + testRepo = NewMediaFileRepository(ctx, GetDBXBuilder(), nil) + }) + + It("sorts by created_at", func() { + // Get results sorted by recently_added (should use created_at) + results, err := testRepo.GetAll(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(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 + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + testRepo = NewMediaFileRepository(ctx, GetDBXBuilder(), nil) + }) + + It("sorts by updated_at", func() { + // Get results sorted by recently_added (should use updated_at) + results, err := testRepo.GetAll(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 + }) + }) + + }) + }) + + Describe("Search", func() { + Context("text search", func() { + It("finds media files by title", func() { + results, err := mr.Search("Antenna", 0, 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("antenna", 0, 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("nonexistent", 0, 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(&mediaFileWithMBID) + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + // Clean up test data using direct SQL + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": mediaFileWithMBID.ID})) + }) + + It("finds media file by mbz_recording_id", func() { + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440020", 0, 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("550e8400-e29b-41d4-a716-446655440021", 0, 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("550e8400-e29b-41d4-a716-446655440099", 0, 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(&missingMediaFile) + Expect(err).ToNot(HaveOccurred()) + + // Search never returns missing media files (hardcoded behavior) + results, err := mr.Search("550e8400-e29b-41d4-a716-446655440022", 0, 10) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(BeEmpty()) + + // Clean up + _, _ = raw.executeSQL(squirrel.Delete(raw.tableName).Where(squirrel.Eq{"id": missingMediaFile.ID})) + }) + }) + }) +}) diff --git a/persistence/meilisearch.go b/persistence/meilisearch.go new file mode 100644 index 0000000..7e4c28d --- /dev/null +++ b/persistence/meilisearch.go @@ -0,0 +1,191 @@ +package persistence + +import ( + "encoding/json" + "fmt" + + "github.com/meilisearch/meilisearch-go" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type MeilisearchService struct { + client meilisearch.ServiceManager +} + +func NewMeilisearchService() *MeilisearchService { + if !conf.Server.Meilisearch.Enabled { + return nil + } + client := meilisearch.New(conf.Server.Meilisearch.Host, meilisearch.WithAPIKey(conf.Server.Meilisearch.ApiKey)) + return &MeilisearchService{client: client} +} + +func (s *MeilisearchService) IndexMediaFile(mf *model.MediaFile) { + if s == nil { + return + } + s.IndexMediaFiles([]model.MediaFile{*mf}) +} + +func (s *MeilisearchService) IndexMediaFiles(mfs []model.MediaFile) { + if s == nil || len(mfs) == 0 { + return + } + docs := make([]map[string]interface{}, len(mfs)) + for i, mf := range mfs { + docs[i] = map[string]interface{}{ + "id": mf.ID, + "title": mf.Title, + "artist": mf.Artist, + "album": mf.Album, + "albumArtist": mf.AlbumArtist, + "path": mf.Path, + "year": mf.Year, + "genre": mf.Genre, + } + } + _, err := s.client.Index("mediafiles").AddDocuments(docs, nil) + if err != nil { + log.Error("Error indexing mediafiles", "count", len(mfs), err) + } +} + +func (s *MeilisearchService) DeleteMediaFile(id string) { + if s == nil { + return + } + _, err := s.client.Index("mediafiles").DeleteDocument(id) + if err != nil { + log.Error("Error deleting mediafile from index", "id", id, err) + } +} + +func (s *MeilisearchService) DeleteMediaFiles(ids []string) { + if s == nil { + return + } + _, err := s.client.Index("mediafiles").DeleteDocuments(ids) + if err != nil { + log.Error("Error deleting mediafiles from index", "ids", ids, err) + } +} + +func (s *MeilisearchService) IndexAlbum(album *model.Album) { + if s == nil { + return + } + s.IndexAlbums([]model.Album{*album}) +} + +func (s *MeilisearchService) IndexAlbums(albums []model.Album) { + if s == nil || len(albums) == 0 { + return + } + docs := make([]map[string]interface{}, len(albums)) + for i, album := range albums { + docs[i] = map[string]interface{}{ + "id": album.ID, + "name": album.Name, + "artist": album.AlbumArtist, + "albumArtist": album.AlbumArtist, + "year": album.MinYear, + "genre": album.Genre, + } + } + _, err := s.client.Index("albums").AddDocuments(docs, nil) + if err != nil { + log.Error("Error indexing albums", "count", len(albums), err) + } +} + +func (s *MeilisearchService) DeleteAlbum(id string) { + if s == nil { + return + } + _, err := s.client.Index("albums").DeleteDocument(id) + if err != nil { + log.Error("Error deleting album from index", "id", id, err) + } +} + +func (s *MeilisearchService) DeleteAlbums(ids []string) { + if s == nil { + return + } + _, err := s.client.Index("albums").DeleteDocuments(ids) + if err != nil { + log.Error("Error deleting albums from index", "ids", ids, err) + } +} + +func (s *MeilisearchService) IndexArtist(artist *model.Artist) { + if s == nil { + return + } + s.IndexArtists([]model.Artist{*artist}) +} + +func (s *MeilisearchService) IndexArtists(artists []model.Artist) { + if s == nil || len(artists) == 0 { + return + } + docs := make([]map[string]interface{}, len(artists)) + for i, artist := range artists { + docs[i] = map[string]interface{}{ + "id": artist.ID, + "name": artist.Name, + } + } + _, err := s.client.Index("artists").AddDocuments(docs, nil) + if err != nil { + log.Error("Error indexing artists", "count", len(artists), err) + } +} + +func (s *MeilisearchService) DeleteArtist(id string) { + if s == nil { + return + } + _, err := s.client.Index("artists").DeleteDocument(id) + if err != nil { + log.Error("Error deleting artist from index", "id", id, err) + } +} + +func (s *MeilisearchService) DeleteArtists(ids []string) { + if s == nil { + return + } + _, err := s.client.Index("artists").DeleteDocuments(ids) + if err != nil { + log.Error("Error deleting artists from index", "ids", ids, err) + } +} + +func (s *MeilisearchService) Search(indexName string, query string, offset, limit int) ([]string, error) { + if s == nil { + return nil, fmt.Errorf("meilisearch is not enabled") + } + searchRes, err := s.client.Index(indexName).Search(query, &meilisearch.SearchRequest{ + Offset: int64(offset), + Limit: int64(limit), + }) + if err != nil { + return nil, err + } + + var ids []string + for _, hit := range searchRes.Hits { + if id, ok := hit["id"]; ok { + var idStr string + if err := json.Unmarshal(id, &idStr); err == nil { + ids = append(ids, idStr) + } + } + } + // Handle case where id might be non-string if necessary, though simpler is better for now. + // Meilisearch returns map[string]interface{} + return ids, nil +} diff --git a/persistence/persistence.go b/persistence/persistence.go new file mode 100644 index 0000000..081437b --- /dev/null +++ b/persistence/persistence.go @@ -0,0 +1,246 @@ +package persistence + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "time" + + "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 + ms *MeilisearchService +} + +func New(conn *sql.DB) model.DataStore { + return &SQLStore{ + db: dbx.NewFromDB(conn, db.Driver), + ms: NewMeilisearchService(), + } +} + +func (s *SQLStore) Album(ctx context.Context) model.AlbumRepository { + return NewAlbumRepository(ctx, s.getDBXBuilder(), s.ms) +} + +func (s *SQLStore) Artist(ctx context.Context) model.ArtistRepository { + return NewArtistRepository(ctx, s.getDBXBuilder(), s.ms) +} + +func (s *SQLStore) MediaFile(ctx context.Context) model.MediaFileRepository { + return NewMediaFileRepository(ctx, s.getDBXBuilder(), s.ms) +} + +func (s *SQLStore) Library(ctx context.Context) model.LibraryRepository { + return NewLibraryRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Folder(ctx context.Context) model.FolderRepository { + return newFolderRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Genre(ctx context.Context) model.GenreRepository { + return NewGenreRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Tag(ctx context.Context) model.TagRepository { + return NewTagRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { + return NewPlayQueueRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Playlist(ctx context.Context) model.PlaylistRepository { + return NewPlaylistRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Property(ctx context.Context) model.PropertyRepository { + return NewPropertyRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Radio(ctx context.Context) model.RadioRepository { + return NewRadioRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) UserProps(ctx context.Context) model.UserPropsRepository { + return NewUserPropsRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Share(ctx context.Context) model.ShareRepository { + return NewShareRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) User(ctx context.Context) model.UserRepository { + return NewUserRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Transcoding(ctx context.Context) model.TranscodingRepository { + return NewTranscodingRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Player(ctx context.Context) model.PlayerRepository { + return NewPlayerRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + return NewScrobbleBufferRepository(ctx, s.getDBXBuilder()) +} + +func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository { + return NewScrobbleRepository(ctx, s.getDBXBuilder()) +} + +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) + case model.Tag: + return s.Tag(ctx).(model.ResourceRepository) + } + log.Error("Resource not implemented", "model", reflect.TypeOf(m).Name()) + return nil +} + +func (s *SQLStore) ReindexAll(ctx context.Context) error { + if s.ms == nil { + return nil + } + log.Info("Starting full re-index") + // Index Artists + artists, err := s.Artist(ctx).GetAll() + if err != nil { + return fmt.Errorf("fetching artists: %w", err) + } + s.ms.IndexArtists(artists) + log.Info(ctx, "Indexed artists", "count", len(artists)) + + // Index Albums + albums, err := s.Album(ctx).GetAll() + if err != nil { + return fmt.Errorf("fetching albums: %w", err) + } + s.ms.IndexAlbums(albums) + log.Info(ctx, "Indexed albums", "count", len(albums)) + + // Index MediaFiles + mfs, err := s.MediaFile(ctx).GetAll() + if err != nil { + return fmt.Errorf("fetching media files: %w", err) + } + batchSize := 2000 + for i := 0; i < len(mfs); i += batchSize { + end := i + batchSize + if end > len(mfs) { + end = len(mfs) + } + s.ms.IndexMediaFiles(mfs[i:end]) + log.Info(ctx, "Indexed media files batch", "start", i, "end", end) + } + return nil +} + +func (s *SQLStore) WithTx(block func(tx model.DataStore) error, scope ...string) error { + var msg string + if len(scope) > 0 { + msg = scope[0] + } + 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 := &SQLStore{db: 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) + } + return err + }) +} + +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(ctx).Put("tmp_lock_flag", "") + defer func() { + _ = tx.Property(ctx).Delete("tmp_lock_flag") + }() + + return block(tx) + }, scope...) +} + +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(ctx).(*albumRepository).purgeEmpty(libraryIDs...) }), + trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }), + trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }), + trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty(libraryIDs...) }), + trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }), + trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }), + trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }), + trace(ctx, "clean media file bookmarks", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanBookmarks() }), + trace(ctx, "purge non used tags", func() error { return s.Tag(ctx).(*tagRepository).purgeUnused() }), + trace(ctx, "remove orphan playlist tracks", func() error { return s.Playlist(ctx).(*playlistRepository).removeOrphans() }), + ) + if err != nil { + log.Error(ctx, "Error tidying up database", err) + } + return err +} + +func (s *SQLStore) getDBXBuilder() dbx.Builder { + if s.db == nil { + return dbx.NewFromDB(db.Db(), db.Driver) + } + return s.db +} diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go new file mode 100644 index 0000000..158f02f --- /dev/null +++ b/persistence/persistence_suite_test.go @@ -0,0 +1,271 @@ +package persistence + +import ( + "context" + "path/filepath" + "testing" + + _ "github.com/mattn/go-sqlite3" + "github.com/navidrome/navidrome/conf" + "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/tests" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +func TestPersistence(t *testing.T) { + tests.Init(t, true) + + //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) + 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 +} + +var ( + artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"} + artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"} + testArtists = model.Artists{ + artistKraftwerk, + artistBeatles, + } +) + +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}) + testAlbums = model.Albums{ + albumSgtPeppers, + albumAbbeyRoad, + albumRadioactivity, + albumMultiDisc, + } +) + +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: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(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"}) + testSongs = model.MediaFiles{ + songDayInALife, + songComeTogether, + songRadioactivity, + songAntenna, + songAntennaWithLyrics, + songAntenna2, + songDisc2Track11, + songDisc1Track01, + songDisc2Track01, + songDisc1Track02, + } +) + +var ( + radioWithoutHomePage = model.Radio{ID: "1235", StreamUrl: "https://example.com:8000/1/stream.mp3", HomePageUrl: "", Name: "No Homepage"} + radioWithHomePage = model.Radio{ID: "5010", StreamUrl: "https://example.com/stream.mp3", Name: "Example Radio", HomePageUrl: "https://example.com"} + testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage} +) + +var ( + plsBest model.Playlist + plsCool model.Playlist + 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"} + testUsers = model.Users{adminUser, regularUser} +) + +func p(path string) string { + return filepath.FromSlash(path) +} + +// Initialize test DB +// TODO Load this data setup from file(s) +var _ = BeforeSuite(func() { + conn := GetDBXBuilder() + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + + ur := NewUserRepository(ctx, conn) + for i := range testUsers { + err := ur.Put(&testUsers[i]) + if err != nil { + panic(err) + } + } + + // Associate users with library 1 (default test library) + for i := range testUsers { + err := ur.SetUserLibraries(testUsers[i].ID, []int{1}) + if err != nil { + panic(err) + } + } + + alr := NewAlbumRepository(ctx, conn, nil).(*albumRepository) + for i := range testAlbums { + a := testAlbums[i] + err := alr.Put(&a) + if err != nil { + panic(err) + } + } + + arr := NewArtistRepository(ctx, conn, nil) + for i := range testArtists { + a := testArtists[i] + err := arr.Put(&a) + if err != nil { + panic(err) + } + } + + // Associate artists with library 1 (default test library) + lr := NewLibraryRepository(ctx, conn) + for i := range testArtists { + err := lr.AddArtist(1, testArtists[i].ID) + if err != nil { + panic(err) + } + } + + mr := NewMediaFileRepository(ctx, conn, nil) + for i := range testSongs { + err := mr.Put(&testSongs[i]) + if err != nil { + panic(err) + } + } + + rar := NewRadioRepository(ctx, conn) + for i := range testRadios { + r := testRadios[i] + err := rar.Put(&r) + if err != nil { + panic(err) + } + } + + plsBest = model.Playlist{ + Name: "Best", + Comment: "No Comments", + OwnerID: "userid", + OwnerName: "userid", + Public: true, + SongCount: 2, + } + plsBest.AddMediaFilesByID([]string{"1001", "1003"}) + plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"} + plsCool.AddMediaFilesByID([]string{"1004"}) + testPlaylists = []*model.Playlist{&plsBest, &plsCool} + + pr := NewPlaylistRepository(ctx, conn) + for i := range testPlaylists { + err := pr.Put(testPlaylists[i]) + if err != nil { + panic(err) + } + } + + // Prepare annotations + if err := arr.SetStar(true, artistBeatles.ID); err != nil { + panic(err) + } + ar, err := arr.Get(artistBeatles.ID) + if err != nil { + panic(err) + } + if ar == nil { + panic("artist not found after SetStar") + } + artistBeatles.Starred = true + artistBeatles.StarredAt = ar.StarredAt + testArtists[1] = artistBeatles + + if err := alr.SetStar(true, albumRadioactivity.ID); err != nil { + panic(err) + } + al, err := alr.Get(albumRadioactivity.ID) + if err != nil { + panic(err) + } + if al == nil { + panic("album not found after SetStar") + } + albumRadioactivity.Starred = true + albumRadioactivity.StarredAt = al.StarredAt + testAlbums[2] = albumRadioactivity + + if err := mr.SetStar(true, songComeTogether.ID); err != nil { + panic(err) + } + mf, err := mr.Get(songComeTogether.ID) + if err != nil { + panic(err) + } + songComeTogether.Starred = true + songComeTogether.StarredAt = mf.StarredAt + testSongs[1] = songComeTogether +}) + +func GetDBXBuilder() *dbx.DB { + return dbx.NewFromDB(db.Db(), db.Dialect) +} diff --git a/persistence/persistence_test.go b/persistence/persistence_test.go new file mode 100644 index 0000000..13e56bd --- /dev/null +++ b/persistence/persistence_test.go @@ -0,0 +1,58 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SQLStore", func() { + var ds model.DataStore + var ctx context.Context + BeforeEach(func() { + ds = New(db.Db()) + ctx = context.Background() + }) + 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(ctx) + err := pl.Put(&model.Player{ID: "666", UserId: "userid"}) + Expect(err).ToNot(HaveOccurred()) + + pr := tx.Property(ctx) + err = pr.Put("777", "value") + Expect(err).ToNot(HaveOccurred()) + return nil + }) + Expect(err).ToNot(HaveOccurred()) + Expect(ds.Player(ctx).Get("666")).To(Equal(&model.Player{ID: "666", UserId: "userid", 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(ctx) + err := pr.Put("999", "value") + Expect(err).ToNot(HaveOccurred()) + + // Will fail as it is missing the UserName + pl := tx.Player(ctx) + err = pl.Put(&model.Player{ID: "888"}) + Expect(err).To(HaveOccurred()) + return err + }) + Expect(err).To(HaveOccurred()) + _, err = ds.Property(ctx).Get("999") + Expect(err).To(MatchError(model.ErrNotFound)) + _, err = ds.Player(ctx).Get("888") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + }) +}) diff --git a/persistence/player_repository.go b/persistence/player_repository.go new file mode 100644 index 0000000..73c8207 --- /dev/null +++ b/persistence/player_repository.go @@ -0,0 +1,169 @@ +package persistence + +import ( + "context" + "errors" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type playerRepository struct { + sqlRepository +} + +func NewPlayerRepository(ctx context.Context, db dbx.Builder) model.PlayerRepository { + r := &playerRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.Player{}, map[string]filterFunc{ + "name": containsFilter("player.name"), + }) + r.setSortMappings(map[string]string{ + "user_name": "username", //TODO rename all user_name and userName to username + }) + return r +} + +func (r *playerRepository) Put(p *model.Player) error { + _, err := r.put(p.ID, p) + return err +} + +func (r *playerRepository) selectPlayer(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(options...). + Columns("player.*"). + Join("user ON player.user_id = user.id"). + Columns("user.user_name username") +} + +func (r *playerRepository) Get(id string) (*model.Player, error) { + sel := r.selectPlayer().Where(Eq{"player.id": id}) + var res model.Player + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *playerRepository) FindMatch(userId, client, userAgent string) (*model.Player, error) { + sel := r.selectPlayer().Where(And{ + Eq{"client": client}, + Eq{"user_agent": userAgent}, + Eq{"user_id": userId}, + }) + var res model.Player + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBuilder { + s := r.selectPlayer(options...) + return s.Where(r.addRestriction()) +} + +func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { + s := And{} + if len(sql) > 0 { + s = append(s, sql[0]) + } + u := loggedUser(r.ctx) + if u.IsAdmin { + return s + } + return append(s, Eq{"user_id": u.ID}) +} + +func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { + sel := r.newSelect(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 + } + err := r.queryAll(sel, &res) + if err != nil { + return nil, err + } + counts := make(map[string]int64, len(res)) + for _, c := range res { + counts[c.Player] = c.Count + } + return counts, nil +} + +func (r *playerRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.newRestSelect(), options...) +} + +func (r *playerRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *playerRepository) Read(id string) (interface{}, error) { + sel := r.newRestSelect().Where(Eq{"player.id": id}) + var res model.Player + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *playerRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newRestSelect(r.parseRestOptions(r.ctx, options...)) + res := model.Players{} + err := r.queryAll(sel, &res) + return res, err +} + +func (r *playerRepository) EntityName() string { + return "player" +} + +func (r *playerRepository) NewInstance() interface{} { + return &model.Player{} +} + +func (r *playerRepository) isPermitted(p *model.Player) bool { + u := loggedUser(r.ctx) + return u.IsAdmin || p.UserId == u.ID +} + +func (r *playerRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Player) + if !r.isPermitted(t) { + return "", rest.ErrPermissionDenied + } + id, err := r.put(t.ID, t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return id, err +} + +func (r *playerRepository) Update(id string, entity interface{}, cols ...string) error { + t := entity.(*model.Player) + t.ID = id + if !r.isPermitted(t) { + return rest.ErrPermissionDenied + } + _, err := r.put(id, t, cols...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +func (r *playerRepository) Delete(id string) error { + filter := r.addRestriction(And{Eq{"player.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 = (*playerRepository)(nil) +var _ rest.Persistable = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go new file mode 100644 index 0000000..f6c6694 --- /dev/null +++ b/persistence/player_repository_test.go @@ -0,0 +1,247 @@ +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" + "github.com/pocketbase/dbx" +) + +var _ = Describe("PlayerRepository", func() { + var adminRepo *playerRepository + var database *dbx.DB + + 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 := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, adminUser) + + database = GetDBXBuilder() + adminRepo = NewPlayerRepository(ctx, database).(*playerRepository) + + for idx := range players { + err := adminRepo.Put(&players[idx]) + Expect(err).To(BeNil()) + } + }) + + AfterEach(func() { + items, err := adminRepo.ReadAll() + Expect(err).To(BeNil()) + players, ok := items.(model.Players) + Expect(ok).To(BeTrue()) + for i := range players { + err = adminRepo.Delete(players[i].ID) + Expect(err).To(BeNil()) + } + }) + + Describe("EntityName", func() { + It("returns the right name", func() { + Expect(adminRepo.EntityName()).To(Equal("player")) + }) + }) + + Describe("FindMatch", func() { + It("finds existing match", func() { + player, err := adminRepo.FindMatch(adminUser.ID, "NavidromeUI", "Firefox/Linux") + Expect(err).To(BeNil()) + Expect(*player).To(Equal(adminPlayer1)) + }) + + It("doesn't find bad match", func() { + _, err := adminRepo.FindMatch(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(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*player).To(Equal(adminPlayer1)) + }) + + It("Gets an existing item from another user", func() { + player, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(*player).To(Equal(regularPlayer)) + }) + + It("does not get nonexistent item", func() { + _, err := adminRepo.Get("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 + + BeforeEach(func() { + if admin { + repo = adminRepo + } else { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + repo = NewPlayerRepository(ctx, database).(*playerRepository) + } + }) + + baseCount := int64(len(players)) + + Describe("Count", func() { + It("should return all", func() { + count, err := repo.Count() + Expect(err).To(BeNil()) + Expect(count).To(Equal(baseCount)) + }) + }) + + Describe("Delete", func() { + DescribeTable("item type", func(player model.Player) { + err := repo.Delete(player.ID) + Expect(err).To(BeNil()) + + isReal := player.UserId != "" + canDelete := admin || player.UserId == userPlayer.UserId + + count, err := repo.Count() + Expect(err).To(BeNil()) + + if isReal && canDelete { + Expect(count).To(Equal(baseCount - 1)) + } else { + Expect(count).To(Equal(baseCount)) + } + + item, err := repo.Get(player.ID) + if !isReal || canDelete { + Expect(err).To(Equal(model.ErrNotFound)) + } else { + Expect(*item).To(Equal(player)) + } + }, + Entry("same user", userPlayer), + Entry("other item", otherPlayer), + Entry("fake item", model.Player{}), + ) + }) + + Describe("Read", func() { + It("can read from current user", func() { + player, err := repo.Read(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(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("i don't exist") + Expect(err).To(Equal(model.ErrNotFound)) + }) + }) + + Describe("ReadAll", func() { + It("should get all items", func() { + data, err := repo.ReadAll() + Expect(err).To(BeNil()) + Expect(data).To(Equal(players)) + }) + }) + + Describe("Save", func() { + DescribeTable("item type", func(player model.Player) { + clone := player + clone.ID = "" + clone.IP = "192.168.1.1" + id, err := repo.Save(&clone) + + if clone.UserId == "" { + Expect(err).To(HaveOccurred()) + } else if !admin && player.Username == adminPlayer1.Username { + Expect(err).To(Equal(rest.ErrPermissionDenied)) + clone.UserId = "" + } else { + Expect(err).To(BeNil()) + Expect(id).ToNot(BeEmpty()) + } + + count, err := repo.Count() + Expect(err).To(BeNil()) + + clone.ID = id + newItem, err := repo.Get(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()) + Expect(*newItem).To(Equal(clone)) + } + }, + Entry("same user", userPlayer), + Entry("other item", otherPlayer), + Entry("fake item", model.Player{}), + ) + }) + + Describe("Update", func() { + DescribeTable("item type", func(player model.Player) { + clone := player + clone.IP = "192.168.1.1" + clone.MaxBitRate = 10000 + err := repo.Update(clone.ID, &clone, "ip") + + if clone.UserId == "" { + Expect(err).To(HaveOccurred()) + } else if !admin && player.Username == adminPlayer1.Username { + Expect(err).To(Equal(rest.ErrPermissionDenied)) + clone.IP = player.IP + } else { + Expect(err).To(BeNil()) + } + + clone.MaxBitRate = player.MaxBitRate + newItem, err := repo.Get(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), + ) +}) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go new file mode 100644 index 0000000..3fdd19a --- /dev/null +++ b/persistence/playlist_repository.go @@ -0,0 +1,528 @@ +package persistence + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "slices" + "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/model/criteria" + "github.com/pocketbase/dbx" +) + +type playlistRepository struct { + sqlRepository +} + +type dbPlaylist struct { + model.Playlist `structs:",flatten"` + Rules sql.NullString `structs:"-"` +} + +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) + } + return nil + } + delete(args, "rules") + return nil +} + +func NewPlaylistRepository(ctx context.Context, db dbx.Builder) model.PlaylistRepository { + r := &playlistRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.Playlist{}, map[string]filterFunc{ + "q": playlistFilter, + "smart": smartPlaylistFilter, + }) + r.setSortMappings(map[string]string{ + "owner_name": "owner_name", + }) + return r +} + +func playlistFilter(_ string, value interface{}) Sqlizer { + return Or{ + substringFilter("playlist.name", value), + substringFilter("playlist.comment", value), + } +} + +func smartPlaylistFilter(string, interface{}) Sqlizer { + return Or{ + Eq{"rules": ""}, + Eq{"rules": nil}, + } +} + +func (r *playlistRepository) userFilter() Sqlizer { + user := loggedUser(r.ctx) + if user.IsAdmin { + return And{} + } + return Or{ + Eq{"public": true}, + Eq{"owner_id": user.ID}, + } +} + +func (r *playlistRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sq := Select().Where(r.userFilter()) + return r.count(sq, options...) +} + +func (r *playlistRepository) Exists(id string) (bool, error) { + return r.exists(And{Eq{"id": id}, r.userFilter()}) +} + +func (r *playlistRepository) Delete(id string) error { + usr := loggedUser(r.ctx) + if !usr.IsAdmin { + pls, err := r.Get(id) + if err != nil { + return err + } + if pls.OwnerID != usr.ID { + return rest.ErrPermissionDenied + } + } + return r.delete(And{Eq{"id": id}, r.userFilter()}) +} + +func (r *playlistRepository) Put(p *model.Playlist) error { + pls := dbPlaylist{Playlist: *p} + if pls.ID == "" { + pls.CreatedAt = time.Now() + } else { + ok, err := r.Exists(pls.ID) + if err != nil { + return err + } + if !ok { + return model.ErrNotAuthorized + } + } + pls.UpdatedAt = time.Now() + + 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(id, p.MediaFiles()) + } + return r.refreshCounters(&pls.Playlist) +} + +func (r *playlistRepository) Get(id string) (*model.Playlist, error) { + return r.findBy(And{Eq{"playlist.id": id}, r.userFilter()}) +} + +func (r *playlistRepository) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*model.Playlist, error) { + pls, err := r.Get(id) + if err != nil { + return nil, err + } + if refreshSmartPlaylist { + r.refreshSmartPlaylist(pls) + } + tracks, err := r.loadTracks(Select().From("playlist_tracks"). + Where(Eq{"missing": false}). + OrderBy("playlist_tracks.id"), id) + if err != nil { + log.Error(r.ctx, "Error loading playlist tracks ", "playlist", pls.Name, "id", pls.ID, err) + return nil, err + } + pls.SetTracks(tracks) + return pls, nil +} + +func (r *playlistRepository) FindByPath(path string) (*model.Playlist, error) { + return r.findBy(Eq{"path": path}) +} + +func (r *playlistRepository) findBy(sql Sqlizer) (*model.Playlist, error) { + sel := r.selectPlaylist().Where(sql) + var pls []dbPlaylist + err := r.queryAll(sel, &pls) + if err != nil { + return nil, err + } + if len(pls) == 0 { + return nil, model.ErrNotFound + } + + return &pls[0].Playlist, nil +} + +func (r *playlistRepository) GetAll(options ...model.QueryOptions) (model.Playlists, error) { + sel := r.selectPlaylist(options...).Where(r.userFilter()) + var res []dbPlaylist + 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 + } + return playlists, err +} + +func (r *playlistRepository) GetPlaylists(mediaFileId string) (model.Playlists, error) { + sel := r.selectPlaylist(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()}) + var res []dbPlaylist + err := r.queryAll(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 + } + return playlists, nil +} + +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") +} + +func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + // Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config + if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) { + return false + } + + // Never refresh other users' playlists + usr := loggedUser(r.ctx) + if pls.OwnerID != usr.ID { + log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) + return false + } + + log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) + start := time.Now() + + // Remove old tracks + del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) + _, err := r.executeSQL(del) + if err != nil { + log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + // Re-populate playlist based on Smart Playlist criteria + rules := *pls.Rules + + // If the playlist depends on other playlists, recursively refresh them first + childPlaylistIds := rules.ChildPlaylistIds() + for _, id := range childPlaylistIds { + childPls, err := r.Get(id) + if err != nil { + log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) + return false + } + r.refreshSmartPlaylist(childPls) + } + + sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + From("media_file").LeftJoin("annotation on (" + + "annotation.item_id = media_file.id" + + " AND annotation.item_type = 'media_file'" + + " AND annotation.user_id = '" + usr.ID + "')") + + // Only include media files from libraries the user has access to + sq = r.applyLibraryFilter(sq, "media_file") + + // Apply the criteria rules + sq = r.addCriteria(sq, rules) + insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) + _, err = r.executeSQL(insSql) + if err != nil { + log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + // Update playlist stats + err = r.refreshCounters(pls) + if err != nil { + log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + // Update when the playlist was last refreshed (for cache purposes) + 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) + 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(id, ids) +} + +func (r *playlistRepository) updatePlaylist(playlistId string, mediaFileIds []string) error { + if !r.isWritable(playlistId) { + return rest.ErrPermissionDenied + } + + // Remove old tracks + del := Delete("playlist_tracks").Where(Eq{"playlist_id": playlistId}) + _, err := r.executeSQL(del) + if err != nil { + return err + } + + return r.addTracks(playlistId, 1, mediaFileIds) +} + +func (r *playlistRepository) addTracks(playlistId string, startingPos int, mediaFileIds []string) error { + // Break the track list in chunks to avoid hitting SQLITE_MAX_VARIABLE_NUMBER limit + // Add new tracks, chunk by chunk + pos := startingPos + for chunk := range slices.Chunk(mediaFileIds, 200) { + ins := Insert("playlist_tracks").Columns("playlist_id", "media_file_id", "id") + for _, t := range chunk { + ins = ins.Values(playlistId, t, pos) + pos++ + } + _, err := r.executeSQL(ins) + if err != nil { + return err + } + } + + return r.refreshCounters(&model.Playlist{ID: playlistId}) +} + +// refreshCounters updates total playlist duration, size and count +func (r *playlistRepository) refreshCounters(pls *model.Playlist) error { + statsSql := Select( + "coalesce(sum(duration), 0) as duration", + "coalesce(sum(size), 0) 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(statsSql, &res) + if err != nil { + return err + } + + // Update playlist's total duration, size and count + upd := Update("playlist"). + Set("duration", res.Duration). + Set("size", res.Size). + Set("song_count", res.Count). + Set("updated_at", time.Now()). + Where(Eq{"id": pls.ID}) + _, err = r.executeSQL(upd) + if err != nil { + return err + } + pls.SongCount = int(res.Count) + pls.Duration = res.Duration + pls.Size = int64(res.Size) + return nil +} + +func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.PlaylistTracks, error) { + sel = r.applyLibraryFilter(sel, "f") + userID := loggedUser(r.ctx).ID + tracksQuery := sel. + 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", + ). + LeftJoin("annotation on (" + + "annotation.item_id = media_file_id" + + " AND annotation.item_type = 'media_file'" + + " AND annotation.user_id = '" + userID + "')"). + Join("media_file f on f.id = media_file_id"). + Join("library on f.library_id = library.id"). + Where(Eq{"playlist_id": id}) + tracks := dbPlaylistTracks{} + err := r.queryAll(tracksQuery, &tracks) + if err != nil { + return nil, err + } + return tracks.toModels(), err +} + +func (r *playlistRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *playlistRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *playlistRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +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(id string, entity interface{}, cols ...string) error { + pls := dbPlaylist{Playlist: *entity.(*model.Playlist)} + current, err := r.Get(id) + if err != nil { + return err + } + usr := loggedUser(r.ctx) + if !usr.IsAdmin { + // Only the owner can update the playlist + if current.OwnerID != usr.ID { + return rest.ErrPermissionDenied + } + // Regular users can't change the ownership of a playlist + if pls.OwnerID != "" && pls.OwnerID != usr.ID { + return rest.ErrPermissionDenied + } + } + pls.ID = id + pls.UpdatedAt = time.Now() + _, err = r.put(id, pls, append(cols, "updatedAt")...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +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"). + Where(Eq{"mf.id": nil}). + GroupBy("playlist_tracks.playlist_id") + + var pls []struct{ Id, Name string } + err := r.queryAll(sel, &pls) + if err != nil { + return fmt.Errorf("fetching playlists with orphan tracks: %w", err) + } + + for _, pl := range pls { + 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(del) + if n == 0 || err != nil { + return fmt.Errorf("deleting orphan tracks from playlist %s: %w", pl.Name, err) + } + 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(pl.Id); err != nil { + return fmt.Errorf("renumbering playlist %s: %w", pl.Name, err) + } + } + return nil +} + +func (r *playlistRepository) renumber(id string) error { + var ids []string + sq := Select("media_file_id").From("playlist_tracks").Where(Eq{"playlist_id": id}).OrderBy("id") + err := r.queryAllSlice(sq, &ids) + 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 + } + pls, err := r.Get(playlistId) + return err == nil && pls.OwnerID == usr.ID +} + +var _ model.PlaylistRepository = (*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 new file mode 100644 index 0000000..7fbe327 --- /dev/null +++ b/persistence/playlist_repository_test.go @@ -0,0 +1,501 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("PlaylistRepository", func() { + var repo model.PlaylistRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("returns the number of playlists in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) + + Describe("Exists", func() { + It("returns true for an existing playlist", func() { + Expect(repo.Exists(plsCool.ID)).To(BeTrue()) + }) + It("returns false for a non-existing playlist", func() { + Expect(repo.Exists("666")).To(BeFalse()) + }) + }) + + Describe("Get", func() { + It("returns an existing playlist", func() { + p, err := repo.Get(plsBest.ID) + Expect(err).To(BeNil()) + // Compare all but Tracks and timestamps + p2 := *p + p2.Tracks = plsBest.Tracks + p2.UpdatedAt = plsBest.UpdatedAt + p2.CreatedAt = plsBest.CreatedAt + Expect(p2).To(Equal(plsBest)) + // Compare tracks + for i := range p.Tracks { + Expect(p.Tracks[i].ID).To(Equal(plsBest.Tracks[i].ID)) + } + }) + It("returns ErrNotFound for a non-existing playlist", func() { + _, err := repo.Get("666") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + It("returns all tracks", func() { + pls, err := repo.GetWithTracks(plsBest.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal(plsBest.Name)) + Expect(pls.Tracks).To(HaveLen(2)) + Expect(pls.Tracks[0].ID).To(Equal("1")) + Expect(pls.Tracks[0].PlaylistID).To(Equal(plsBest.ID)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + Expect(pls.Tracks[0].MediaFile.ID).To(Equal(songDayInALife.ID)) + Expect(pls.Tracks[1].ID).To(Equal("2")) + Expect(pls.Tracks[1].PlaylistID).To(Equal(plsBest.ID)) + Expect(pls.Tracks[1].MediaFileID).To(Equal(songRadioactivity.ID)) + Expect(pls.Tracks[1].MediaFile.ID).To(Equal(songRadioactivity.ID)) + mfs := pls.MediaFiles() + Expect(mfs).To(HaveLen(2)) + Expect(mfs[0].ID).To(Equal(songDayInALife.ID)) + Expect(mfs[1].ID).To(Equal(songRadioactivity.ID)) + }) + }) + + It("Put/Exists/Delete", func() { + By("saves the playlist to the DB") + newPls := model.Playlist{Name: "Great!", OwnerID: "userid"} + newPls.AddMediaFilesByID([]string{"1004", "1003"}) + + By("saves the playlist to the DB") + Expect(repo.Put(&newPls)).To(BeNil()) + + By("adds repeated songs to a playlist and keeps the order") + newPls.AddMediaFilesByID([]string{"1004"}) + Expect(repo.Put(&newPls)).To(BeNil()) + saved, _ := repo.GetWithTracks(newPls.ID, true, false) + 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(newPls.ID)).To(BeTrue()) + + By("returns deletes the playlist") + Expect(repo.Delete(newPls.ID)).To(BeNil()) + + By("returns error if tries to retrieve the deleted playlist") + Expect(repo.Exists(newPls.ID)).To(BeFalse()) + }) + + Describe("GetAll", func() { + It("returns all playlists from DB", func() { + 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(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("9999") + Expect(err).ToNot(HaveOccurred()) + Expect(pls).To(HaveLen(0)) + }) + }) + + Context("Smart Playlists", func() { + var rules *criteria.Criteria + BeforeEach(func() { + rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "love"}, + }, + } + }) + Context("valid rules", func() { + Specify("Put/Get", func() { + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + + savedPls, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedPls.Rules).To(Equal(rules)) + }) + }) + + Context("invalid rules", func() { + It("fails to Put it in the DB", func() { + rules = &criteria.Criteria{ + // This is invalid because "contains" cannot have multiple fields + Expression: criteria.All{ + criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, + }, + } + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) + }) + }) + + // TODO Validate these tests + XContext("child smart playlists", func() { + When("refresh day has expired", func() { + It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&nestedPls)).To(Succeed()) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + _, err = repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + // Check that the nested playlist was refreshed by parent get by verifying evaluatedAt is updated since first nestedPls get + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally(">", *nestedPlsRead.EvaluatedAt)) + }) + }) + + When("refresh day 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 + + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&nestedPls)).To(Succeed()) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + _, err = repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + // Check that the nested playlist was not refreshed by parent get by verifying evaluatedAt is not updated since first nestedPls get + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) + }) + }) + }) + }) + + Describe("Playlist Track Sorting", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + It("sorts tracks correctly by album (disc and track number)", func() { + By("creating a playlist with multi-disc album tracks in arbitrary order") + newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} + // Add tracks in intentionally scrambled order + newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("retrieving tracks sorted by album") + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + Expect(err).ToNot(HaveOccurred()) + + By("verifying tracks are sorted by disc number then track number") + Expect(tracks).To(HaveLen(4)) + // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 + Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 + Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 + Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 + Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 + }) + }) + + Describe("Smart Playlists with Tag Criteria", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var songWithGrouping, songWithoutGrouping model.MediaFile + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder(), nil) + + // Register 'grouping' as a valid tag for smart playlists + criteria.AddTagNames([]string{"grouping"}) + + // Create a song with the grouping tag + songWithGrouping = model.MediaFile{ + ID: "test-grouping-1", + Title: "Song With Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "/test/grouping/song1.mp3", + Tags: model.Tags{ + "grouping": []string{"My Crate"}, + }, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) + + // Create a song without the grouping tag + songWithoutGrouping = model.MediaFile{ + ID: "test-grouping-2", + Title: "Song Without Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "/test/grouping/song2.mp3", + Tags: model.Tags{}, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) + }) + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test media files + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() + }) + + It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { + By("creating a smart playlist that checks if grouping tag has any value") + // This is the workaround for issue #4728: using 'contains' with empty string + // generates SQL: value LIKE '%%' which matches any non-empty string + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track with grouping tag is matched") + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) + }) + + It("excludes tracks with a tag value using 'notContains' with empty string", func() { + By("creating a smart playlist that checks if grouping tag is NOT set") + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.NotContains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying the track with grouping is NOT in the playlist") + for _, track := range pls.Tracks { + Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) + } + + By("verifying the track without grouping IS in the playlist") + var foundWithoutGrouping bool + for _, track := range pls.Tracks { + if track.MediaFileID == songWithoutGrouping.ID { + foundWithoutGrouping = true + break + } + } + Expect(foundWithoutGrouping).To(BeTrue()) + }) + }) + + Describe("Smart Playlists Library Filtering", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var lib2ID int + var restrictedUserID string + var uniqueLibPath string + + BeforeEach(func() { + db := GetDBXBuilder() + + // Generate unique IDs for this test run + uniqueSuffix := time.Now().Format("20060102150405.000") + restrictedUserID = "restricted-user-" + uniqueSuffix + uniqueLibPath = "/music/lib2-" + uniqueSuffix + + // Create a second library with unique name and path to avoid conflicts with other tests + _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) + Expect(err).ToNot(HaveOccurred()) + err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) + Expect(err).ToNot(HaveOccurred()) + + // Create a restricted user with access only to library 1 + _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + + // Create test media files in each library + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, db, nil) + + // Song in library 1 (accessible by restricted user) + songLib1 := model.MediaFile{ + ID: "lib1-song", + Title: "Song in Lib1", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "/music/lib1/song.mp3", + LibraryID: 1, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib1)).To(Succeed()) + + // Song in library 2 (NOT accessible by restricted user) + songLib2 := model.MediaFile{ + ID: "lib2-song", + Title: "Song in Lib2", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: uniqueLibPath + "/song.mp3", + LibraryID: lib2ID, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib2)).To(Succeed()) + }) + + AfterEach(func() { + db := GetDBXBuilder() + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test data + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() + _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() + _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() + _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) + }) + + It("should only include tracks from libraries the user has access to (issue #4738)", func() { + db := GetDBXBuilder() + ctx := log.NewContext(GinkgoT().Context()) + + // Create the smart playlist as the restricted user + restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} + ctx = request.WithUser(ctx, restrictedUser) + restrictedRepo := NewPlaylistRepository(ctx, db) + + // Create a smart playlist that matches all songs + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Gt{"playCount": -1}, // Matches everything + }, + } + newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} + Expect(restrictedRepo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track from library 1 is in the playlist") + var foundLib1Song, foundLib2Song bool + for _, track := range pls.Tracks { + if track.MediaFileID == "lib1-song" { + foundLib1Song = true + } + if track.MediaFileID == "lib2-song" { + foundLib2Song = true + } + } + Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") + Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") + + By("verifying playlist_tracks table only contains the accessible track") + var playlistTracksCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) + Expect(err).ToNot(HaveOccurred()) + // Count should only include tracks visible to the user (lib1-song) + // The count may include other test songs from library 1, but NOT lib2-song + var lib2TrackCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") + + By("verifying SongCount matches visible tracks") + Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") + }) + }) +}) diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go new file mode 100644 index 0000000..666f227 --- /dev/null +++ b/persistence/playlist_track_repository.go @@ -0,0 +1,247 @@ +package persistence + +import ( + "database/sql" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" +) + +type playlistTrackRepository struct { + sqlRepository + 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(playlistId string, refreshSmartPlaylist bool) model.PlaylistTrackRepository { + p := &playlistTrackRepository{} + p.playlistRepo = r + p.playlistId = playlistId + p.ctx = r.ctx + p.db = r.db + 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, title", + "title": "order_title", + // 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(playlistId) + if err != nil { + log.Warn(r.ctx, "Error getting playlist's tracks", "playlistId", playlistId, err) + return nil + } + if refreshSmartPlaylist { + r.refreshSmartPlaylist(pls) + } + p.playlist = pls + return p +} + +func (r *playlistTrackRepository) Count(options ...rest.QueryOptions) (int64, error) { + query := Select(). + LeftJoin("media_file f on f.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + return r.count(query, r.parseRestOptions(r.ctx, options...)) +} + +func (r *playlistTrackRepository) Read(id string) (interface{}, error) { + userID := loggedUser(r.ctx).ID + 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.*", + ). + Join("media_file f on f.id = media_file_id"). + Where(And{Eq{"playlist_id": r.playlistId}, Eq{"playlist_tracks.id": id}}) + var trk dbPlaylistTrack + err := r.queryOne(sel, &trk) + return trk.PlaylistTrack, err +} + +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 + } + return tracks, err +} + +func (r *playlistTrackRepository) GetAlbumIDs(options ...model.QueryOptions) ([]string, error) { + query := r.newSelect(options...).Columns("distinct mf.album_id"). + Join("media_file mf on mf.id = media_file_id"). + Where(Eq{"playlist_id": r.playlistId}) + var ids []string + err := r.queryAllSlice(query, &ids) + if err != nil { + return nil, err + } + return ids, nil +} + +func (r *playlistTrackRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +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(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) + } else { + return 0, nil + } + + // Get next pos (ID) in playlist + sq := r.newSelect().Columns("max(id) as max").Where(Eq{"playlist_id": r.playlistId}) + var res struct{ Max sql.NullInt32 } + err := r.queryOne(sq, &res) + if err != nil { + return 0, err + } + + return len(mediaFileIds), r.playlistRepo.addTracks(r.playlistId, int(res.Max.Int32+1), mediaFileIds) +} + +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(sq, &ids) + if err != nil { + log.Error(r.ctx, "Error getting tracks to add to playlist", err) + return 0, err + } + return r.Add(ids) +} + +func (r *playlistTrackRepository) AddAlbums(albumIds []string) (int, error) { + return r.addMediaFileIds(Eq{"album_id": albumIds}) +} + +func (r *playlistTrackRepository) AddArtists(artistIds []string) (int, error) { + return r.addMediaFileIds(Eq{"album_artist_id": artistIds}) +} + +func (r *playlistTrackRepository) AddDiscs(discs []model.DiscID) (int, error) { + if len(discs) == 0 { + return 0, nil + } + var clauses Or + 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(clauses) +} + +// Get ids from all current tracks +func (r *playlistTrackRepository) getTracks() ([]string, error) { + all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id") + var ids []string + err := r.queryAllSlice(all, &ids) + if err != nil { + log.Error(r.ctx, "Error querying current tracks from playlist", "playlistId", r.playlistId, err) + return nil, err + } + return ids, nil +} + +func (r *playlistTrackRepository) Delete(ids ...string) error { + if !r.isTracksEditable() { + return rest.ErrPermissionDenied + } + err := r.delete(And{Eq{"playlist_id": r.playlistId}, Eq{"id": ids}}) + if err != nil { + return err + } + + return r.playlistRepo.renumber(r.playlistId) +} + +func (r *playlistTrackRepository) DeleteAll() error { + if !r.isTracksEditable() { + return rest.ErrPermissionDenied + } + err := r.delete(Eq{"playlist_id": r.playlistId}) + if err != nil { + return err + } + + 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 + } + newOrder := slice.Move(ids, pos-1, newPos-1) + return r.playlistRepo.updatePlaylist(r.playlistId, newOrder) +} + +var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil) diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go new file mode 100644 index 0000000..5f3aaf9 --- /dev/null +++ b/persistence/playqueue_repository.go @@ -0,0 +1,178 @@ +package persistence + +import ( + "context" + "errors" + "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 playQueueRepository struct { + sqlRepository +} + +func NewPlayQueueRepository(ctx context.Context, db dbx.Builder) model.PlayQueueRepository { + r := &playQueueRepository{} + r.ctx = ctx + r.db = db + r.tableName = "playqueue" + return r +} + +type playQueue struct { + ID string `structs:"id"` + UserID string `structs:"user_id"` + Current int `structs:"current"` + Position int64 `structs:"position"` + ChangedBy string `structs:"changed_by"` + Items string `structs:"items"` + CreatedAt time.Time `structs:"created_at"` + UpdatedAt time.Time `structs:"updated_at"` +} + +func (r *playQueueRepository) Store(q *model.PlayQueue, colNames ...string) error { + u := loggedUser(r.ctx) + + // Always find existing playqueue for this user + existingQueue, err := r.Retrieve(q.UserID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + log.Error(r.ctx, "Error retrieving existing 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(q.UserID) + if err != nil { + log.Error(r.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(pq.ID, pq, colNames...) + if err != nil { + log.Error(r.ctx, "Error saving playqueue", "user", u.UserName, err) + return err + } + return nil +} + +func (r *playQueueRepository) RetrieveWithMediaFiles(userId string) (*model.PlayQueue, error) { + sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) + var res playQueue + err := r.queryOne(sel, &res) + q := r.toModel(&res) + q.Items = r.loadTracks(q.Items) + return &q, err +} + +func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) { + sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) + var res playQueue + err := r.queryOne(sel, &res) + q := r.toModel(&res) + return &q, err +} + +func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { + pq := playQueue{ + ID: q.ID, + UserID: q.UserID, + Current: q.Current, + Position: q.Position, + ChangedBy: q.ChangedBy, + CreatedAt: q.CreatedAt, + UpdatedAt: q.UpdatedAt, + } + var itemIDs []string + for _, t := range q.Items { + itemIDs = append(itemIDs, t.ID) + } + pq.Items = strings.Join(itemIDs, ",") + return pq +} + +func (r *playQueueRepository) toModel(pq *playQueue) model.PlayQueue { + q := model.PlayQueue{ + ID: pq.ID, + UserID: pq.UserID, + Current: pq.Current, + Position: pq.Position, + ChangedBy: pq.ChangedBy, + CreatedAt: pq.CreatedAt, + UpdatedAt: pq.UpdatedAt, + } + if strings.TrimSpace(pq.Items) != "" { + tracks := strings.Split(pq.Items, ",") + for _, t := range tracks { + q.Items = append(q.Items, model.MediaFile{ID: t}) + } + } + 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(tracks model.MediaFiles) model.MediaFiles { + if len(tracks) == 0 { + return nil + } + + mfRepo := NewMediaFileRepository(r.ctx, r.db, nil) + 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(model.QueryOptions{Filters: idsFilter}) + if err != nil { + 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 + } + } + + // 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) + } + } + return newTracks +} + +func (r *playQueueRepository) clearPlayQueue(userId string) error { + return r.delete(Eq{"user_id": userId}) +} + +func (r *playQueueRepository) Clear(userId string) error { + return r.clearPlayQueue(userId) +} + +var _ model.PlayQueueRepository = (*playQueueRepository)(nil) diff --git a/persistence/playqueue_repository_test.go b/persistence/playqueue_repository_test.go new file mode 100644 index 0000000..170fef9 --- /dev/null +++ b/persistence/playqueue_repository_test.go @@ -0,0 +1,435 @@ +package persistence + +import ( + "context" + "time" + + "github.com/Masterminds/squirrel" + "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" +) + +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(ctx, GetDBXBuilder()) + }) + + Describe("Store", func() { + It("stores a complete playqueue", func() { + expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) + Expect(repo.Store(expected)).To(Succeed()) + + actual, err := repo.RetrieveWithMediaFiles("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(initial)).To(Succeed()) + + By("Storing replacement playqueue") + replacement := aPlayQueue("userid", 1, 200, songDayInALife, songAntenna) + Expect(repo.Store(replacement)).To(Succeed()) + + actual, err := repo.RetrieveWithMediaFiles("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(initial)).To(Succeed()) + + By("Storing empty playqueue") + empty := aPlayQueue("userid", 0, 0) + Expect(repo.Store(empty)).To(Succeed()) + + By("Verifying playqueue is cleared") + _, err := repo.Retrieve("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(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("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(update, "current")).To(Succeed()) + + By("Verifying only current was updated") + actual, err := repo.Retrieve("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(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("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(update, "position")).To(Succeed()) + + By("Verifying only position was updated") + actual, err := repo.Retrieve("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(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("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(update, "current", "position")).To(Succeed()) + + By("Verifying both fields were updated") + actual, err := repo.Retrieve("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(initial)).To(Succeed()) + + By("Getting the existing playqueue to obtain its ID") + existing, err := repo.Retrieve("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(update, "position")).To(Succeed()) + + By("Verifying items are preserved") + actual, err := repo.Retrieve("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(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(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("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(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(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("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() { + It("returns notfound error if there's no playqueue for the user", func() { + _, err := repo.Retrieve("user999") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("retrieves the playqueue with only track IDs (no full MediaFile data)", func() { + By("Storing a playqueue for the user") + + expected := aPlayQueue("userid", 1, 123, songComeTogether, songDayInALife) + Expect(repo.Store(expected)).To(Succeed()) + + actual, err := repo.Retrieve("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(ctx, GetDBXBuilder(), nil) + + Expect(mfRepo.Put(&newSong)).To(Succeed()) + + // Create a playqueue with the new song + pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) + Expect(repo.Store(pq)).To(Succeed()) + + // Delete the new song from the database + Expect(mfRepo.Delete("temp-track")).To(Succeed()) + + // Retrieve the playqueue with Retrieve method + actual, err := repo.Retrieve("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("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(expected)).To(Succeed()) + + actual, err := repo.RetrieveWithMediaFiles("userid") + Expect(err).ToNot(HaveOccurred()) + + 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(ctx, GetDBXBuilder(), nil) + + Expect(mfRepo.Put(&newSong)).To(Succeed()) + + // Create a playqueue with the new song + pq := aPlayQueue("userid", 0, 0, newSong, songAntenna) + Expect(repo.Store(pq)).To(Succeed()) + + // Retrieve the playqueue + actual, err := repo.RetrieveWithMediaFiles("userid") + Expect(err).ToNot(HaveOccurred()) + + // The playqueue should contain both tracks + AssertPlayQueue(pq, actual) + + // Delete the new song + Expect(mfRepo.Delete("temp-track")).To(Succeed()) + + // Retrieve the playqueue + actual, err = repo.RetrieveWithMediaFiles("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(expected)).To(Succeed()) + + By("Verifying playqueue exists") + _, err := repo.Retrieve("userid") + Expect(err).ToNot(HaveOccurred()) + + By("Clearing the playqueue") + Expect(repo.Clear("userid")).To(Succeed()) + + By("Verifying playqueue is cleared") + _, err = repo.Retrieve("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("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(ctx, 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(user1)).To(Succeed()) + Expect(userRepo.Put(user2)).To(Succeed()) + + By("Storing playqueues for two users") + user1Queue := aPlayQueue("user1", 0, 100, songComeTogether) + user2Queue := aPlayQueue("user2", 1, 200, songDayInALife) + Expect(repo.Store(user1Queue)).To(Succeed()) + Expect(repo.Store(user2Queue)).To(Succeed()) + + By("Clearing only user1's playqueue") + Expect(repo.Clear("user1")).To(Succeed()) + + By("Verifying user1's playqueue is cleared") + _, err := repo.Retrieve("user1") + Expect(err).To(MatchError(model.ErrNotFound)) + + By("Verifying user2's playqueue still exists") + actual, err := repo.Retrieve("user2") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.UserID).To(Equal("user2")) + Expect(actual.Current).To(Equal(1)) + Expect(actual.Position).To(Equal(int64(200))) + }) + }) +}) + +func countPlayQueues(repo model.PlayQueueRepository, userId string) int { + r := repo.(*playQueueRepository) + c, err := r.count(squirrel.Select().Where(squirrel.Eq{"user_id": userId})) + if err != nil { + panic(err) + } + return int(c) +} + +func AssertPlayQueue(expected, actual *model.PlayQueue) { + 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))) + for i, item := range actual.Items { + Expect(item.Title).To(Equal(expected.Items[i].Title)) + } +} + +func aPlayQueue(userId string, current int, position int64, items ...model.MediaFile) *model.PlayQueue { + createdAt := time.Now() + updatedAt := createdAt.Add(time.Minute) + return &model.PlayQueue{ + ID: id.NewRandom(), + UserID: userId, + Current: current, + Position: position, + ChangedBy: "test", + Items: items, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + } +} diff --git a/persistence/property_repository.go b/persistence/property_repository.go new file mode 100644 index 0000000..14f9051 --- /dev/null +++ b/persistence/property_repository.go @@ -0,0 +1,63 @@ +package persistence + +import ( + "context" + "errors" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type propertyRepository struct { + sqlRepository +} + +func NewPropertyRepository(ctx context.Context, db dbx.Builder) model.PropertyRepository { + r := &propertyRepository{} + r.ctx = ctx + r.db = db + r.tableName = "property" + return r +} + +func (r propertyRepository) Put(id string, value string) error { + update := Update(r.tableName).Set("value", value).Where(Eq{"id": id}) + count, err := r.executeSQL(update) + if err != nil { + return err + } + if count > 0 { + return nil + } + insert := Insert(r.tableName).Columns("id", "value").Values(id, value) + _, err = r.executeSQL(insert) + return err +} + +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(sel, &resp) + if err != nil { + return "", err + } + return resp.Value, nil +} + +func (r propertyRepository) DefaultGet(id string, defaultValue string) (string, error) { + value, err := r.Get(id) + if errors.Is(err, model.ErrNotFound) { + return defaultValue, nil + } + if err != nil { + return defaultValue, err + } + return value, nil +} + +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 new file mode 100644 index 0000000..3a0495e --- /dev/null +++ b/persistence/property_repository_test.go @@ -0,0 +1,34 @@ +package persistence + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Property Repository", func() { + var pr model.PropertyRepository + + BeforeEach(func() { + pr = NewPropertyRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + }) + + It("saves and restore a new property", func() { + id := "1" + value := "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("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("2", "default")).To(Equal("default")) + }) +}) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go new file mode 100644 index 0000000..cf253d0 --- /dev/null +++ b/persistence/radio_repository.go @@ -0,0 +1,139 @@ +package persistence + +import ( + "context" + "errors" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/pocketbase/dbx" +) + +type radioRepository struct { + sqlRepository +} + +func NewRadioRepository(ctx context.Context, db dbx.Builder) model.RadioRepository { + r := &radioRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.Radio{}, map[string]filterFunc{ + "name": containsFilter("name"), + }) + return r +} + +func (r *radioRepository) isPermitted() bool { + user := loggedUser(r.ctx) + return user.IsAdmin +} + +func (r *radioRepository) CountAll(options ...model.QueryOptions) (int64, error) { + sql := r.newSelect() + return r.count(sql, options...) +} + +func (r *radioRepository) Delete(id string) error { + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + + return r.delete(Eq{"id": id}) +} + +func (r *radioRepository) Get(id string) (*model.Radio, error) { + sel := r.newSelect().Where(Eq{"id": id}).Columns("*") + res := model.Radio{} + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, error) { + sel := r.newSelect(options...).Columns("*") + res := model.Radios{} + err := r.queryAll(sel, &res) + return res, err +} + +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() + 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 + } + } + + values["created_at"] = time.Now() + insert := Insert(r.tableName).SetMap(values) + _, err := r.executeSQL(insert) + return err +} + +func (r *radioRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *radioRepository) EntityName() string { + return "radio" +} + +func (r *radioRepository) NewInstance() interface{} { + return &model.Radio{} +} + +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(r.ctx, options...)) +} + +func (r *radioRepository) Save(entity interface{}) (string, error) { + t := entity.(*model.Radio) + if !r.isPermitted() { + return "", rest.ErrPermissionDenied + } + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return t.ID, err +} + +func (r *radioRepository) Update(id string, entity interface{}, cols ...string) error { + t := entity.(*model.Radio) + t.ID = id + if !r.isPermitted() { + return rest.ErrPermissionDenied + } + err := r.Put(t) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +var _ model.RadioRepository = (*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 new file mode 100644 index 0000000..88a31ac --- /dev/null +++ b/persistence/radio_repository_test.go @@ -0,0 +1,175 @@ +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 ( + NewId string = "123-456-789" +) + +var _ = Describe("RadioRepository", func() { + var repo model.RadioRepository + + Describe("Admin User", func() { + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewRadioRepository(ctx, GetDBXBuilder()) + _ = repo.Put(&radioWithHomePage) + }) + + AfterEach(func() { + all, _ := repo.GetAll() + + for _, radio := range all { + _ = repo.Delete(radio.ID) + } + + for i := range testRadios { + r := testRadios[i] + err := repo.Put(&r) + if err != nil { + panic(err) + } + } + }) + + Describe("Count", func() { + It("returns the number of radios in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) + + Describe("Delete", func() { + It("deletes existing item", func() { + err := repo.Delete(radioWithHomePage.ID) + + Expect(err).To(BeNil()) + + _, err = repo.Get(radioWithHomePage.ID) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Get", func() { + It("returns an existing item", func() { + 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("notanid") + + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all items from the DB", func() { + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) + Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) + }) + }) + + Describe("Put", func() { + It("successfully updates item", func() { + err := repo.Put(&model.Radio{ + ID: radioWithHomePage.ID, + Name: "New Name", + StreamUrl: "https://example.com:4533/app", + }) + + Expect(err).To(BeNil()) + + item, err := repo.Get(radioWithHomePage.ID) + Expect(err).To(BeNil()) + + Expect(item.HomePageUrl).To(Equal("")) + }) + + It("successfully creates item", func() { + err := repo.Put(&model.Radio{ + Name: "New radio", + StreamUrl: "https://example.com:4533/app", + }) + + Expect(err).To(BeNil()) + Expect(repo.CountAll()).To(Equal(int64(3))) + + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all[2].StreamUrl).To(Equal("https://example.com:4533/app")) + }) + }) + }) + + Describe("Regular User", func() { + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: false}) + repo = NewRadioRepository(ctx, GetDBXBuilder()) + }) + + Describe("Count", func() { + It("returns the number of radios in the DB", func() { + Expect(repo.CountAll()).To(Equal(int64(2))) + }) + }) + + Describe("Delete", func() { + It("fails to delete items", func() { + err := repo.Delete(radioWithHomePage.ID) + + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + + Describe("Get", func() { + It("returns an existing item", func() { + 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("notanid") + + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("GetAll", func() { + It("returns all items from the DB", func() { + all, err := repo.GetAll() + Expect(err).To(BeNil()) + Expect(all[0].ID).To(Equal(radioWithoutHomePage.ID)) + Expect(all[1].ID).To(Equal(radioWithHomePage.ID)) + }) + }) + + Describe("Put", func() { + It("fails to update item", func() { + err := repo.Put(&model.Radio{ + ID: radioWithHomePage.ID, + Name: "New Name", + StreamUrl: "https://example.com:4533/app", + }) + + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }) + }) + }) +}) diff --git a/persistence/scrobble_buffer_repository.go b/persistence/scrobble_buffer_repository.go new file mode 100644 index 0000000..d0f8890 --- /dev/null +++ b/persistence/scrobble_buffer_repository.go @@ -0,0 +1,100 @@ +package persistence + +import ( + "context" + "errors" + "time" + + . "github.com/Masterminds/squirrel" + "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(ctx context.Context, db dbx.Builder) model.ScrobbleBufferRepository { + r := &scrobbleBufferRepository{} + r.ctx = ctx + r.db = db + r.tableName = "scrobble_buffer" + return r +} + +func (r *scrobbleBufferRepository) UserIDs(service string) ([]string, error) { + sql := Select().Columns("user_id"). + From(r.tableName). + Where(And{ + Eq{"service": service}, + }). + GroupBy("user_id"). + OrderBy("count(*)") + var userIds []string + err := r.queryAllSlice(sql, &userIds) + return userIds, err +} + +func (r *scrobbleBufferRepository) Enqueue(service, userId, mediaFileId string, playTime time.Time) error { + ins := Insert(r.tableName).SetMap(map[string]interface{}{ + "id": id.NewRandom(), + "user_id": userId, + "service": service, + "media_file_id": mediaFileId, + "play_time": playTime, + "enqueue_time": time.Now(), + }) + _, err := r.executeSQL(ins) + return err +} + +func (r *scrobbleBufferRepository) Next(service string, userId string) (*model.ScrobbleEntry, error) { + // Put `s.*` last or else m.id overrides s.id + sql := Select().Columns("m.*, s.*"). + From(r.tableName+" s"). + LeftJoin("media_file m on m.id = s.media_file_id"). + Where(And{ + Eq{"service": service}, + Eq{"user_id": userId}, + }). + OrderBy("play_time", "s.rowid").Limit(1) + + var res dbScrobbleBuffer + err := r.queryOne(sql, &res) + if errors.Is(err, model.ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + res.ScrobbleEntry.Participants, err = r.getParticipants(&res.ScrobbleEntry.MediaFile) + if err != nil { + return nil, err + } + return res.ScrobbleEntry, nil +} + +func (r *scrobbleBufferRepository) Dequeue(entry *model.ScrobbleEntry) error { + return r.delete(Eq{"id": entry.ID}) +} + +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 new file mode 100644 index 0000000..62423ff --- /dev/null +++ b/persistence/scrobble_buffer_repository_test.go @@ -0,0 +1,208 @@ +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 + + 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]interface{}{ + "id": id, + "user_id": userId, + "service": service, + "media_file_id": mediaFileId, + "play_time": playTime, + "enqueue_time": enqueueTime, + }) + _, err := rawRepo.executeSQL(ins) + Expect(err).ToNot(HaveOccurred()) + } + + BeforeEach(func() { + ctx := request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid", UserName: "johndoe", IsAdmin: true}) + db := GetDBXBuilder() + scrobble = NewScrobbleBufferRepository(ctx, db) + + rawRepo = sqlRepository{ + ctx: ctx, + tableName: "scrobble_buffer", + db: db, + } + ids = []string{} + }) + + AfterEach(func() { + del := squirrel.Delete(rawRepo.tableName) + _, err := rawRepo.executeSQL(del) + Expect(err).ToNot(HaveOccurred()) + }) + + Describe("Without data", func() { + Describe("Count", func() { + It("returns zero when empty", func() { + count, err := scrobble.Length() + 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(&model.ScrobbleEntry{ID: "fake"}) + Expect(err).ToNot(HaveOccurred()) + + count, err := scrobble.Length() + 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("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("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() + 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(&model.ScrobbleEntry{ID: "fake"}) + Expect(err).ToNot(HaveOccurred()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(4))) + }) + + It("deletes an item when specified properly", func() { + err := scrobble.Dequeue(&model.ScrobbleEntry{ID: ids[3]}) + Expect(err).ToNot(HaveOccurred()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(3))) + + entry, err := scrobble.Next("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(service, userId, fileId, playTime) + Expect(err).ToNot(HaveOccurred()) + + count, err := scrobble.Length() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(5))) + + entry, err := scrobble.Next(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(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("UserIds", func() { + It("should return ordered list for services", func() { + ids, err := scrobble.UserIDs("a") + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]string{"2222", "userid"})) + }) + + It("should return for a different service", func() { + ids, err := scrobble.UserIDs("b") + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(Equal([]string{"2222"})) + }) + }) + }) +}) diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go new file mode 100644 index 0000000..dda98b7 --- /dev/null +++ b/persistence/scrobble_repository.go @@ -0,0 +1,34 @@ +package persistence + +import ( + "context" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type scrobbleRepository struct { + sqlRepository +} + +func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository { + r := &scrobbleRepository{} + r.ctx = ctx + r.db = db + r.tableName = "scrobbles" + return r +} + +func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error { + userID := loggedUser(r.ctx).ID + values := map[string]interface{}{ + "media_file_id": mediaFileID, + "user_id": userID, + "submission_time": submissionTime.Unix(), + } + insert := Insert(r.tableName).SetMap(values) + _, err := r.executeSQL(insert) + return err +} diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go new file mode 100644 index 0000000..d43848d --- /dev/null +++ b/persistence/scrobble_repository_test.go @@ -0,0 +1,84 @@ +package persistence + +import ( + "context" + "time" + + "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 rawRepo sqlRepository + var ctx context.Context + var fileID string + var userID string + + 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(ctx, db) + + rawRepo = sqlRepository{ + ctx: ctx, + 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() + }) + + Describe("RecordScrobble", func() { + 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(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())) + }) + }) +}) diff --git a/persistence/share_repository.go b/persistence/share_repository.go new file mode 100644 index 0000000..e4b97c2 --- /dev/null +++ b/persistence/share_repository.go @@ -0,0 +1,202 @@ +package persistence + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "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 +} + +func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareRepository { + r := &shareRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.Share{}, nil) + r.setSortMappings(map[string]string{ + "username": "username", + }) + return r +} + +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(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(id string) (bool, error) { + return r.exists(Eq{"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(sel, &res) + if err != nil { + return nil, err + } + err = r.loadMedia(&res) + return &res, err +} + +func (r *shareRepository) GetAll(options ...model.QueryOptions) (model.Shares, error) { + sq := r.selectShare(options...) + res := model.Shares{} + err := r.queryAll(sq, &res) + if err != nil { + return nil, err + } + for i := range res { + err = r.loadMedia(&res[i]) + if err != nil { + return nil, fmt.Errorf("error loading media for share %s: %w", res[i].ID, err) + } + } + return res, err +} + +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}} + } + switch share.ResourceType { + case "artist": + albumRepo := NewAlbumRepository(r.ctx, r.db, nil) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) + if err != nil { + return err + } + mfRepo := NewMediaFileRepository(r.ctx, r.db, nil) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_artist_id": ids}), Sort: "artist"}) + return err + case "album": + albumRepo := NewAlbumRepository(r.ctx, r.db, nil) + share.Albums, err = albumRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album.id": ids})}) + if err != nil { + return err + } + mfRepo := NewMediaFileRepository(r.ctx, r.db, nil) + share.Tracks, err = mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"album_id": ids}), Sort: "album"}) + return err + case "playlist": + // 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.db) + tracks, err := plsRepo.Tracks(ids[0], true).GetAll(model.QueryOptions{Sort: "id", Filters: noMissing(Eq{})}) + if err != nil { + return err + } + if len(tracks) >= 0 { + share.Tracks = tracks.MediaFiles() + } + return nil + case "media_file": + mfRepo := NewMediaFileRepository(r.ctx, r.db, nil) + tracks, err := mfRepo.GetAll(model.QueryOptions{Filters: noMissing(Eq{"media_file.id": ids})}) + share.Tracks = sortByIdPosition(tracks, ids) + return err + } + log.Warn(r.ctx, "Unsupported Share ResourceType", "share", share.ID, "resourceType", share.ResourceType) + return nil +} + +func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { + m := map[string]int{} + for i, mf := range mfs { + m[mf.ID] = i + } + var sorted model.MediaFiles + for _, id := range ids { + if idx, ok := m[id]; ok { + sorted = append(sorted, mfs[idx]) + } + } + return sorted +} + +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() + cols = append(cols, "updated_at") + _, err := r.put(id, s, cols...) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +func (r *shareRepository) Save(entity interface{}) (string, error) { + s := entity.(*model.Share) + // TODO Validate record + u := loggedUser(r.ctx) + if s.UserID == "" { + s.UserID = u.ID + } + s.CreatedAt = time.Now() + s.UpdatedAt = time.Now() + id, err := r.put(s.ID, s) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return id, err +} + +func (r *shareRepository) CountAll(options ...model.QueryOptions) (int64, error) { + return r.count(r.selectShare(), options...) +} + +func (r *shareRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +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(sel, &res) + return &res, err +} + +func (r *shareRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sq := r.selectShare(r.parseRestOptions(r.ctx, options...)) + res := model.Shares{} + err := r.queryAll(sq, &res) + return res, err +} + +var _ model.ShareRepository = (*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 new file mode 100644 index 0000000..2521151 --- /dev/null +++ b/persistence/share_repository_test.go @@ -0,0 +1,133 @@ +package persistence + +import ( + "context" + "time" + + "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(context.TODO()), adminUser) + repo = NewShareRepository(ctx, GetDBXBuilder()) + + // Insert the admin user into the database (required for foreign key constraint) + ur := NewUserRepository(ctx, GetDBXBuilder()) + err := ur.Put(&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() { + It("should create repository successfully with no user context", func() { + // Create repository with no user context (headless) + headlessRepo := NewShareRepository(context.Background(), 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]interface{}{ + "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(context.Background(), GetDBXBuilder()) + shares, err := headlessRepo.GetAll() + 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]interface{}{ + "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(context.Background(), GetDBXBuilder()) + share, err := headlessRepo.Get(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]interface{}{ + "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(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()) + }) + }) +}) diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go new file mode 100644 index 0000000..108e9be --- /dev/null +++ b/persistence/sql_annotations.go @@ -0,0 +1,130 @@ +package persistence + +import ( + "database/sql" + "errors" + "fmt" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" +) + +const annotationTable = "annotation" + +func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) SelectBuilder { + userID := loggedUser(r.ctx).ID + if userID == invalidUserId { + return query + } + query = query. + LeftJoin("annotation on ("+ + "annotation.item_id = "+idField+ + " AND annotation.user_id = '"+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") + } + + return query +} + +func (r sqlRepository) annId(itemID ...string) And { + userID := loggedUser(r.ctx).ID + return And{ + Eq{annotationTable + ".user_id": userID}, + Eq{annotationTable + ".item_type": r.tableName}, + Eq{annotationTable + ".item_id": itemID}, + } +} + +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(upd) + if c == 0 || errors.Is(err, sql.ErrNoRows) { + userID := loggedUser(r.ctx).ID + for _, itemID := range itemIDs { + values["user_id"] = userID + values["item_type"] = r.tableName + values["item_id"] = itemID + ins := Insert(annotationTable).SetMap(values) + _, err = r.executeSQL(ins) + if err != nil { + return err + } + } + } + return err +} + +func (r sqlRepository) SetStar(starred bool, ids ...string) error { + starredAt := time.Now() + return r.annUpsert(map[string]interface{}{"starred": starred, "starred_at": starredAt}, ids...) +} + +func (r sqlRepository) SetRating(rating int, itemID string) error { + ratedAt := time.Now() + return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, 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(upd) + + if c == 0 || errors.Is(err, sql.ErrNoRows) { + userID := loggedUser(r.ctx).ID + values := map[string]interface{}{} + values["user_id"] = userID + 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(ins) + if err != nil { + return err + } + } + return err +} + +func (r sqlRepository) ReassignAnnotation(prevID string, newID string) error { + if prevID == newID || prevID == "" || newID == "" { + return nil + } + upd := Update(annotationTable).Where(And{ + Eq{annotationTable + ".item_type": r.tableName}, + Eq{annotationTable + ".item_id": prevID}, + }).Set("item_id", newID) + _, err := r.executeSQL(upd) + return err +} + +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 fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err) + } + if c > 0 { + log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c) + } + return nil +} diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go new file mode 100644 index 0000000..ce026a3 --- /dev/null +++ b/persistence/sql_base_repository.go @@ -0,0 +1,470 @@ +package persistence + +import ( + "context" + "crypto/md5" + "database/sql" + "errors" + "fmt" + "iter" + "reflect" + "regexp" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "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" +) + +// 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 ctx and db fields. ctx should be the context passed to the constructor method, usually obtained from the request +// - 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 { + ctx context.Context + 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 + sortMappings map[string]string +} + +const invalidUserId = "-1" + +func loggedUser(ctx context.Context) *model.User { + if user, ok := request.UserFrom(ctx); !ok { + return &model.User{ID: invalidUserId} + } else { + return &user + } +} + +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. +// +// 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 { + for k, v := range mappings { + v = mapSortOrder(tn, v) + mappings[k] = v + } + } + r.sortMappings = mappings +} + +func (r sqlRepository) newSelect(options ...model.QueryOptions) SelectBuilder { + sq := Select().From(r.tableName) + if len(options) > 0 { + r.resetSeededRandom(options) + sq = r.applyOptions(sq, options...) + sq = r.applyFilters(sq, options...) + } + return sq +} + +func (r sqlRepository) applyOptions(sq SelectBuilder, options ...model.QueryOptions) SelectBuilder { + if len(options) > 0 { + if options[0].Max > 0 { + sq = sq.Limit(uint64(options[0].Max)) + } + if options[0].Offset > 0 { + sq = sq.Offset(uint64(options[0].Offset)) + } + if options[0].Sort != "" { + sq = sq.OrderBy(r.buildSortOrder(options[0].Sort, options[0].Order)) + } + } + return sq +} + +// TODO Change all sortMappings to have a consistent case +func (r sqlRepository) sortMapping(sort string) string { + if mapping, ok := r.sortMappings[sort]; ok { + return mapping + } + if mapping, ok := r.sortMappings[toCamelCase(sort)]; ok { + return mapping + } + sort = toSnakeCase(sort) + if mapping, ok := r.sortMappings[sort]; ok { + return mapping + } + return sort +} + +func (r sqlRepository) buildSortOrder(sort, order string) string { + sort = r.sortMapping(sort) + order = strings.ToLower(strings.TrimSpace(order)) + var reverseOrder string + if order == "desc" { + reverseOrder = "asc" + } else { + order = "asc" + reverseOrder = "desc" + } + + parts := strings.FieldsFunc(sort, splitFunc(',')) + newSort := make([]string, 0, len(parts)) + for _, p := range parts { + f := strings.FieldsFunc(p, splitFunc(' ')) + newField := make([]string, 1, len(f)) + newField[0] = f[0] + if len(f) == 1 { + newField = append(newField, order) + } else { + if f[1] == "asc" { + newField = append(newField, order) + } else { + newField = append(newField, reverseOrder) + } + } + newSort = append(newSort, strings.Join(newField, " ")) + } + 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) + } + return sq +} + +func (r *sqlRepository) withTableName(filter filterFunc) filterFunc { + return func(field string, value any) Sqlizer { + if r.tableName != "" { + field = r.tableName + "." + field + } + return filter(field, value) + } +} + +// libraryIdFilter is a filter function to be added to resources that have a library_id column. +func libraryIdFilter(_ string, value interface{}) 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(sq SelectBuilder, tableName ...string) SelectBuilder { + user := loggedUser(r.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 + } + + 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)) +} + +func (r sqlRepository) seedKey() string { + // Seed keys must be all lowercase, or else SQLite3 will encode it, making it not match the seed + // used in the query. Hashing the user ID and converting it to a hex string will do the trick + userIDHash := md5.Sum([]byte(loggedUser(r.ctx).ID)) + return fmt.Sprintf("%s|%x", r.tableName, userIDHash) +} + +func (r sqlRepository) resetSeededRandom(options []model.QueryOptions) { + if len(options) == 0 || options[0].Sort != "random" { + return + } + options[0].Sort = fmt.Sprintf("SEEDEDRAND('%s', %s.id)", r.seedKey(), r.tableName) + if options[0].Seed != "" { + hasher.SetSeed(r.seedKey(), options[0].Seed) + return + } + if options[0].Offset == 0 { + hasher.Reseed(r.seedKey()) + } +} + +func (r sqlRepository) executeSQL(sq Sqlizer) (int64, error) { + query, args, err := r.toSQL(sq) + if err != nil { + return 0, err + } + start := time.Now() + var c int64 + res, err := r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Execute() + if res != nil { + c, _ = res.RowsAffected() + } + 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 +} + +var placeholderRegex = regexp.MustCompile(`\?`) + +func (r sqlRepository) toSQL(sq Sqlizer) (string, dbx.Params, error) { + query, args, err := sq.ToSql() + if err != nil { + return "", nil, 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 +} + +func (r sqlRepository) queryOne(sq Sqlizer, response interface{}) error { + query, args, err := r.toSQL(sq) + if err != nil { + return err + } + start := time.Now() + err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).One(response) + if errors.Is(err, sql.ErrNoRows) { + r.logSQL(query, args, nil, 0, start) + return model.ErrNotFound + } + r.logSQL(query, args, err, 1, start) + return err +} + +// 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](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(r.ctx).Rows() + r.logSQL(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(sq SelectBuilder, response interface{}, 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(r.ctx).All(response) + if errors.Is(err, sql.ErrNoRows) { + r.logSQL(query, args, nil, -1, start) + return model.ErrNotFound + } + r.logSQL(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(sq SelectBuilder, response interface{}) error { + query, args, err := r.toSQL(sq) + if err != nil { + return err + } + start := time.Now() + err = r.db.NewQuery(query).Bind(args).WithContext(r.ctx).Column(response) + if errors.Is(err, sql.ErrNoRows) { + r.logSQL(query, args, nil, -1, start) + return model.ErrNotFound + } + r.logSQL(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(cond Sqlizer) (bool, error) { + existsQuery := Select("count(*) as exist").From(r.tableName).Where(cond) + var res struct{ Exist int64 } + err := r.queryOne(existsQuery, &res) + return res.Exist > 0, err +} + +func (r sqlRepository) count(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) + countQuery = r.applyFilters(countQuery, options...) + var res struct{ Count int64 } + err := r.queryOne(countQuery, &res) + return res.Count, err +} + +func (r sqlRepository) putByMatch(filter Sqlizer, id string, m interface{}, colsToUpdate ...string) (string, error) { + if id != "" { + return r.put(id, m, colsToUpdate...) + } + existsQuery := r.newSelect().Columns("id").From(r.tableName).Where(filter) + + var res struct{ ID string } + err := r.queryOne(existsQuery, &res) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return "", err + } + return r.put(res.ID, m, colsToUpdate...) +} + +func (r sqlRepository) put(id string, m interface{}, colsToUpdate ...string) (newId string, err error) { + values, err := toSQLArgs(m) + if err != nil { + return "", fmt.Errorf("error preparing values to write to DB: %w", err) + } + // If there's an ID, try to update first + if id != "" { + updateValues := map[string]interface{}{} + + // This is a map of the columns that need to be updated, if specified + c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { + return toSnakeCase(s), struct{}{} + }) + for k, v := range values { + if _, found := c2upd[k]; len(c2upd) == 0 || found { + updateValues[k] = v + } + } + + updateValues["id"] = id + delete(updateValues, "created_at") + // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now + // TODO move to mediafile_repository when each repo has its own upsert method + delete(updateValues, "birth_time") + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + count, err := r.executeSQL(update) + if err != nil { + return "", err + } + if count > 0 { + return id, nil + } + } + // 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() + values["id"] = id + } + insert := Insert(r.tableName).SetMap(values) + _, err = r.executeSQL(insert) + return id, err +} + +func (r sqlRepository) delete(cond Sqlizer) error { + del := Delete(r.tableName).Where(cond) + _, err := r.executeSQL(del) + if errors.Is(err, sql.ErrNoRows) { + return model.ErrNotFound + } + return err +} + +func (r sqlRepository) logSQL(sql string, args dbx.Params, err error, rowsAffected int64, start time.Time) { + elapsed := time.Since(start) + if err == nil || errors.Is(err, context.Canceled) { + log.Trace(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) + } else { + log.Error(r.ctx, "SQL: `"+sql+"`", "args", args, "rowsAffected", rowsAffected, "elapsedTime", elapsed, err) + } +} diff --git a/persistence/sql_base_repository_test.go b/persistence/sql_base_repository_test.go new file mode 100644 index 0000000..b46e206 --- /dev/null +++ b/persistence/sql_base_repository_test.go @@ -0,0 +1,284 @@ +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 + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "user-id"}) + r.tableName = "table" + }) + + 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{}) + sql, _, _ := sq.ToSql() + Expect(sql).To(Equal("SELECT * FROM test")) + }) + It("adds all option clauses", func() { + sq = r.applyOptions(sq, model.QueryOptions{ + Sort: "name", + Order: "desc", + Max: 1, + 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("unknown", "") + Expect(sort).To(BeEmpty()) + }) + + It("returns the mapped value when sort key exists", func() { + sort, _ := r.sanitizeSort("sort1", "") + Expect(sort).To(Equal("mappedSort1")) + }) + + It("is case insensitive", func() { + sort, _ := r.sanitizeSort("Sort1", "") + Expect(sort).To(Equal("mappedSort1")) + }) + + It("returns the field if it is a valid field", func() { + sort, _ := r.sanitizeSort("field", "") + Expect(sort).To(Equal("field")) + }) + + It("is case insensitive for fields", func() { + sort, _ := r.sanitizeSort("FIELD", "") + Expect(sort).To(Equal("field")) + }) + }) + When("sanitizing order", func() { + It("returns 'asc' if order is empty", func() { + _, order := r.sanitizeSort("", "") + Expect(order).To(Equal("")) + }) + + It("returns 'asc' if order is 'asc'", func() { + _, order := r.sanitizeSort("", "ASC") + Expect(order).To(Equal("asc")) + }) + + It("returns 'desc' if order is 'desc'", func() { + _, order := r.sanitizeSort("", "desc") + Expect(order).To(Equal("desc")) + }) + + It("returns 'asc' if order is unknown", func() { + _, order := r.sanitizeSort("", "something") + Expect(order).To(Equal("asc")) + }) + }) + }) + + 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") + Expect(sql).To(Equal("name desc")) + }) + It("defaults to 'asc'", func() { + sql := r.buildSortOrder("name", "") + Expect(sql).To(Equal("name asc")) + }) + It("inverts pre-defined order", func() { + sql := r.buildSortOrder("name desc", "desc") + Expect(sql).To(Equal("name asc")) + }) + It("forces snake case for field names", func() { + sql := r.buildSortOrder("AlbumArtist", "asc") + Expect(sql).To(Equal("album_artist asc")) + }) + }) + Context("multiple fields", func() { + It("handles multiple fields", func() { + sql := r.buildSortOrder("name desc,age asc, status desc ", "asc") + Expect(sql).To(Equal("name desc, age asc, status desc")) + }) + It("inverts multiple fields", 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() { + sql := r.buildSortOrder("substr(id, 7)", "asc") + Expect(sql).To(Equal("substr(id, 7) asc")) + }) + It("handles functions with multiple params mixed with multiple fields", func() { + sql := r.buildSortOrder("name desc, substr(id, 7), status asc", "desc") + Expect(sql).To(Equal("name asc, substr(id, 7) desc, status desc")) + }) + It("handles nested functions", func() { + sql := r.buildSortOrder("name desc, coalesce(nullif(release_date, ''), nullif(original_date, '')), status asc", "desc") + Expect(sql).To(Equal("name asc, coalesce(nullif(release_date, ''), nullif(original_date, '')) desc, status desc")) + }) + }) + }) + + Describe("resetSeededRandom", func() { + var id string + BeforeEach(func() { + id = r.seedKey() + hasher.SetSeed(id, "") + }) + It("does not reset seed if sort is not random", func() { + var options []model.QueryOptions + r.resetSeededRandom(options) + Expect(hasher.CurrentSeed(id)).To(BeEmpty()) + }) + It("resets seed if sort is random", func() { + options := []model.QueryOptions{{Sort: "random"}} + r.resetSeededRandom(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(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(options) + Expect(hasher.CurrentSeed(id)).To(Equal("seed")) + + options = []model.QueryOptions{{Sort: "random", Offset: 1}} + r.resetSeededRandom(options) + Expect(hasher.CurrentSeed(id)).To(Equal("seed")) + }) + }) + + Describe("applyLibraryFilter", func() { + var sq squirrel.SelectBuilder + + BeforeEach(func() { + sq = squirrel.Select("*").From("test_table") + }) + + Context("Admin User", func() { + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "admin", IsAdmin: true}) + }) + + It("should not apply library filter for admin users", func() { + result := r.applyLibraryFilter(sq) + sql, _, _ := result.ToSql() + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + }) + + Context("Regular User", func() { + BeforeEach(func() { + r.ctx = request.WithUser(context.Background(), model.User{ID: "user123", IsAdmin: false}) + }) + + It("should apply library filter for regular users", func() { + result := r.applyLibraryFilter(sq) + sql, args, _ := result.ToSql() + 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(sq, "custom_table") + sql, args, _ := result.ToSql() + Expect(sql).To(ContainSubstring("custom_table.library_id IN")) + Expect(args).To(ContainElement("user123")) + }) + }) + + Context("Headless Process (No User Context)", func() { + BeforeEach(func() { + r.ctx = context.Background() // No user context + }) + + It("should not apply library filter for headless processes", func() { + result := r.applyLibraryFilter(sq) + sql, _, _ := result.ToSql() + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + + It("should not apply library filter even with custom table name", func() { + result := r.applyLibraryFilter(sq, "custom_table") + sql, _, _ := result.ToSql() + Expect(sql).To(Equal("SELECT * FROM test_table")) + }) + }) + }) +}) diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go new file mode 100644 index 0000000..9164aed --- /dev/null +++ b/persistence/sql_bookmarks.go @@ -0,0 +1,157 @@ +package persistence + +import ( + "database/sql" + "errors" + "fmt" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +const bookmarkTable = "bookmark" + +func (r sqlRepository) withBookmark(query SelectBuilder, idField string) SelectBuilder { + userID := loggedUser(r.ctx).ID + if userID == invalidUserId { + return query + } + return query. + LeftJoin("bookmark on (" + + "bookmark.item_id = " + idField + + " AND bookmark.user_id = '" + userID + "')"). + Columns("coalesce(position, 0) as bookmark_position") +} + +func (r sqlRepository) bmkID(itemID ...string) And { + return And{ + Eq{bookmarkTable + ".user_id": loggedUser(r.ctx).ID}, + Eq{bookmarkTable + ".item_type": r.tableName}, + Eq{bookmarkTable + ".item_id": itemID}, + } +} + +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(itemID)).SetMap(values) + c, err := r.executeSQL(upd) + if err == nil { + log.Debug(r.ctx, "Updated bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + } + if c == 0 || errors.Is(err, sql.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(ins) + if err != nil { + return err + } + log.Debug(r.ctx, "Added bookmark", "id", itemID, "user", user.UserName, "position", position, "comment", comment) + } + + return err +} + +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(r.ctx, "Error adding bookmark", "id", id, "user", user.UserName, "position", position, "comment", comment) + } + return err +} + +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(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"` + ItemType string `json:"item_type"` + Comment string `json:"comment"` + Position int64 `json:"position"` + ChangedBy string `json:"changed_by"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (r sqlRepository) GetBookmarks() (model.Bookmarks, error) { + user, _ := request.UserFrom(r.ctx) + + idField := r.tableName + ".id" + sq := r.newSelect().Columns(r.tableName + ".*") + sq = r.withAnnotation(sq, idField) + sq = r.withBookmark(sq, idField).Where(NotEq{bookmarkTable + ".item_id": nil}) + var mfs dbMediaFiles // TODO Decouple from media_file + err := r.queryAll(sq, &mfs) + if err != nil { + log.Error(r.ctx, "Error getting mediafiles with bookmarks", "user", user.UserName, err) + return nil, err + } + + ids := make([]string, len(mfs)) + mfMap := make(map[string]int) + for i, mf := range mfs { + ids[i] = mf.ID + mfMap[mf.ID] = i + } + + sq = Select("*").From(bookmarkTable).Where(r.bmkID(ids...)) + var bmks []bookmark + err = r.queryAll(sq, &bmks) + if err != nil { + 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(r.ctx, "Invalid bookmark", "id", bmk.ItemID, "user", user.UserName) + continue + } else { + resp[i] = model.Bookmark{ + Comment: bmk.Comment, + Position: bmk.Position, + CreatedAt: bmk.CreatedAt, + UpdatedAt: bmk.UpdatedAt, + ChangedBy: bmk.ChangedBy, + Item: *mfs[itemIdx].MediaFile, + } + } + } + return resp, nil +} + +func (r sqlRepository) cleanBookmarks() error { + del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")") + c, err := r.executeSQL(del) + if err != nil { + return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err) + } + if c > 0 { + log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName) + } + return nil +} diff --git a/persistence/sql_bookmarks_test.go b/persistence/sql_bookmarks_test.go new file mode 100644 index 0000000..f7c99f8 --- /dev/null +++ b/persistence/sql_bookmarks_test.go @@ -0,0 +1,74 @@ +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 _ = Describe("sqlBookmarks", func() { + var mr model.MediaFileRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, model.User{ID: "userid"}) + mr = NewMediaFileRepository(ctx, GetDBXBuilder(), nil) + }) + + Describe("Bookmarks", func() { + It("returns an empty collection if there are no bookmarks", func() { + Expect(mr.GetBookmarks()).To(BeEmpty()) + }) + + It("saves and overrides bookmarks", func() { + By("Saving the bookmark") + Expect(mr.AddBookmark(songAntenna.ID, "this is a comment", 123)).To(BeNil()) + + bms, err := mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + + Expect(bms).To(HaveLen(1)) + Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) + Expect(bms[0].Item.Title).To(Equal(songAntenna.Title)) + Expect(bms[0].Comment).To(Equal("this is a comment")) + Expect(bms[0].Position).To(Equal(int64(123))) + created := bms[0].CreatedAt + updated := bms[0].UpdatedAt + Expect(created.IsZero()).To(BeFalse()) + Expect(updated).To(BeTemporally(">=", created)) + + By("Overriding the bookmark") + Expect(mr.AddBookmark(songAntenna.ID, "another comment", 333)).To(BeNil()) + + bms, err = mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + + Expect(bms[0].Item.ID).To(Equal(songAntenna.ID)) + Expect(bms[0].Comment).To(Equal("another comment")) + Expect(bms[0].Position).To(Equal(int64(333))) + Expect(bms[0].CreatedAt).To(Equal(created)) + Expect(bms[0].UpdatedAt).To(BeTemporally(">=", updated)) + + By("Saving another bookmark") + Expect(mr.AddBookmark(songComeTogether.ID, "one more comment", 444)).To(BeNil()) + bms, err = mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + Expect(bms).To(HaveLen(2)) + + By("Delete bookmark") + Expect(mr.DeleteBookmark(songAntenna.ID)).To(Succeed()) + bms, err = mr.GetBookmarks() + Expect(err).ToNot(HaveOccurred()) + 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(songComeTogether.ID)).To(Succeed()) + Expect(mr.GetBookmarks()).To(BeEmpty()) + }) + }) +}) diff --git a/persistence/sql_participations.go b/persistence/sql_participations.go new file mode 100644 index 0000000..fd63e32 --- /dev/null +++ b/persistence/sql_participations.go @@ -0,0 +1,119 @@ +package persistence + +import ( + "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"` +} + +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(itemID string, participants model.Participants) error { + ids := participants.AllIDs() + sqd := Delete(r.tableName + "_artists").Where(And{Eq{r.tableName + "_id": itemID}, NotEq{"artist_id": ids}}) + _, err := r.executeSQL(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(Expr(query, itemID, string(participantsJSON))) + return err +} + +func (r *sqlRepository) getParticipants(m *model.MediaFile) (model.Participants, error) { + artistRepo := NewArtistRepository(r.ctx, r.db, nil) + ids := m.Participants.AllIDs() + artists, err := artistRepo.GetAll(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 new file mode 100644 index 0000000..ff0d06a --- /dev/null +++ b/persistence/sql_restful.go @@ -0,0 +1,180 @@ +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 + +func (r *sqlRepository) parseRestFilters(ctx context.Context, 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, eqFilter(f, v)) + continue + } + // Default to a "starts with" filter + filters = append(filters, startsWithFilter(f, v)) + } + return filters +} + +func (r *sqlRepository) parseRestOptions(ctx context.Context, options ...rest.QueryOptions) model.QueryOptions { + qo := model.QueryOptions{} + if len(options) > 0 { + qo.Sort, qo.Order = r.sanitizeSort(options[0].Sort, 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]) + } + return qo +} + +func (r sqlRepository) sanitizeSort(sort, order string) (string, string) { + if sort != "" { + sort = toSnakeCase(sort) + if mapped, ok := r.sortMappings[sort]; ok { + sort = mapped + } else { + if !r.isFieldWhiteListed(sort) { + log.Warn(r.ctx, "Ignoring sort not whitelisted", "sort", sort, "table", r.tableName) + sort = "" + } + } + } + if order != "" { + order = strings.ToLower(order) + if order != "desc" { + order = "asc" + } + } + return sort, order +} + +func eqFilter(field string, value any) Sqlizer { + return Eq{field: value} +} + +func startsWithFilter(field string, value any) 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 booleanFilter(field string, value any) Sqlizer { + v := strings.ToLower(value.(string)) + return Eq{field: v == "true"} +} + +func fullTextFilter(tableName string, mbidFields ...string) func(string, any) Sqlizer { + return func(field string, value any) Sqlizer { + v := strings.ToLower(value.(string)) + cond := cmp.Or( + mbidExpr(tableName, v, mbidFields...), + fullTextExpr(tableName, v), + ) + return cond + } +} + +func substringFilter(field string, value any) Sqlizer { + parts := strings.Fields(value.(string)) + filters := And{} + for _, part := range parts { + filters = append(filters, Like{field: "%" + part + "%"}) + } + return filters +} + +func idFilter(tableName string) func(string, any) Sqlizer { + return func(field string, value any) Sqlizer { return Eq{tableName + ".id": value} } +} + +func invalidFilter(ctx context.Context) func(string, any) Sqlizer { + return func(field string, value any) Sqlizer { + log.Warn(ctx, "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 + } +} diff --git a/persistence/sql_restful_test.go b/persistence/sql_restful_test.go new file mode 100644 index 0000000..fd95fbb --- /dev/null +++ b/persistence/sql_restful_test.go @@ -0,0 +1,235 @@ +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 options rest.QueryOptions + + BeforeEach(func() { + r = sqlRepository{} + }) + + 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 fullTextExpr("'")`, func() { + r.filterMappings = map[string]filterFunc{ + "name": fullTextFilter("table"), + } + options.Filters = map[string]interface{}{"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]interface{}{"name": "joe"} + Expect(r.parseRestFilters(context.Background(), options)).To(BeEmpty()) + }) + + It("returns a '=' condition for 'id' filter", func() { + options.Filters = map[string]interface{}{"id": "123"} + Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": "123"}})) + }) + + It("returns a 'in' condition for multiples 'id' filters", func() { + options.Filters = map[string]interface{}{"id": []string{"123", "456"}} + Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Eq{"id": []string{"123", "456"}}})) + }) + + It("returns a 'like' condition for other filters", func() { + options.Filters = map[string]interface{}{"name": "joe"} + Expect(r.parseRestFilters(context.Background(), options)).To(Equal(squirrel.And{squirrel.Like{"name": "joe%"}})) + }) + + It("uses the custom filter", func() { + r.filterMappings = map[string]filterFunc{ + "test": func(field string, value interface{}) squirrel.Sqlizer { + return squirrel.Gt{field: value} + }, + } + options.Filters = map[string]interface{}{"test": 100} + Expect(r.parseRestFilters(context.Background(), 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()) + 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 fullTextExpr + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% 550e8400-e29b-41d4-a716-446655440000%"}, + } + Expect(result).To(Equal(expected)) + }) + }) + + 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 fullTextExpr result is returned directly + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% beatles%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles multi-word search terms", func() { + result := filter("search", "the beatles abbey road") + + // Should return And condition directly + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(4)) + + // Check that all words are present (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% the%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% beatles%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% abbey%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% road%"})) + }) + }) + + Context("when SearchFullString config changes behavior", func() { + It("uses different separator with SearchFullString=false", func() { + conf.Server.SearchFullString = false + result := filter("search", "test query") + + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(2)) + + // Check that all words are present with leading space (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% test%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "% query%"})) + }) + + It("uses no separator with SearchFullString=true", func() { + conf.Server.SearchFullString = true + result := filter("search", "test query") + + andCondition, ok := result.(squirrel.And) + Expect(ok).To(BeTrue()) + Expect(andCondition).To(HaveLen(2)) + + // Check that all words are present without leading space (order may vary) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%test%"})) + Expect(andCondition).To(ContainElement(squirrel.Like{"test_table.full_text": "%query%"})) + }) + }) + + 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") + + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% dont%"}, // str.SanitizeStrings removes quotes + } + Expect(result).To(Equal(expected)) + }) + + 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 + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% 550e8400-invalid-uuid%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("handles empty mbid fields array", func() { + emptyMbidFilter := fullTextFilter(tableName, []string{}...) + result := emptyMbidFilter("search", "test") + + // mbidExpr with empty fields returns nil, so cmp.Or falls back to fullTextExpr + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% test%"}, + } + Expect(result).To(Equal(expected)) + }) + + It("converts value to lowercase before processing", func() { + result := filter("search", "TEST") + + // The function converts to lowercase internally + expected := squirrel.And{ + squirrel.Like{"test_table.full_text": "% test%"}, + } + Expect(result).To(Equal(expected)) + }) + }) + }) + +}) diff --git a/persistence/sql_search.go b/persistence/sql_search.go new file mode 100644 index 0000000..0d3bfb7 --- /dev/null +++ b/persistence/sql_search.go @@ -0,0 +1,77 @@ +package persistence + +import ( + "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" +) + +func formatFullText(text ...string) string { + fullText := str.SanitizeStrings(text...) + return " " + fullText +} + +// doSearch performs a full-text search with the specified parameters. +// The naturalOrder is used to sort results when no full-text filter is applied. It is useful for cases like +// OpenSubsonic, where an empty search query should return all results in a natural order. Normally the parameter +// should be `tableName + ".rowid"`, but some repositories (ex: artist) may use a different natural order. +func (r sqlRepository) doSearch(sq SelectBuilder, q string, offset, size int, results any, naturalOrder string, orderBys ...string) error { + q = strings.TrimSpace(q) + q = strings.TrimSuffix(q, "*") + if len(q) < 2 { + return nil + } + + filter := fullTextExpr(r.tableName, q) + if filter != nil { + sq = sq.Where(filter) + sq = sq.OrderBy(orderBys...) + } else { + // This is to speed up the results of `search3?query=""`, for OpenSubsonic + // If the filter is empty, we sort by the specified natural order. + sq = sq.OrderBy(naturalOrder) + } + sq = sq.Where(Eq{r.tableName + ".missing": false}) + sq = sq.Limit(uint64(size)).Offset(uint64(offset)) + return r.queryAll(sq, results, model.QueryOptions{Offset: offset}) +} + +func (r sqlRepository) searchByMBID(sq SelectBuilder, mbid string, mbidFields []string, results any) error { + sq = sq.Where(mbidExpr(r.tableName, mbid, mbidFields...)) + sq = sq.Where(Eq{r.tableName + ".missing": false}) + + return r.queryAll(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) +} + +func fullTextExpr(tableName string, s string) Sqlizer { + q := str.SanitizeStrings(s) + if q == "" { + return nil + } + var sep string + if !conf.Server.SearchFullString { + sep = " " + } + parts := strings.Split(q, " ") + filters := And{} + for _, part := range parts { + filters = append(filters, Like{tableName + ".full_text": "%" + sep + part + "%"}) + } + return filters +} diff --git a/persistence/sql_search_test.go b/persistence/sql_search_test.go new file mode 100644 index 0000000..6bfd88d --- /dev/null +++ b/persistence/sql_search_test.go @@ -0,0 +1,14 @@ +package persistence + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("sqlRepository", func() { + Describe("formatFullText", func() { + It("prefixes with a space", func() { + Expect(formatFullText("legiao urbana")).To(Equal(" legiao urbana")) + }) + }) +}) diff --git a/persistence/sql_tags.go b/persistence/sql_tags.go new file mode 100644 index 0000000..8c3c1e8 --- /dev/null +++ b/persistence/sql_tags.go @@ -0,0 +1,168 @@ +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) +} + +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 interface{}) 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(ctx context.Context, db dbx.Builder, tagFilter *model.TagName) *baseTagRepository { + r := &baseTagRepository{ + tagFilter: tagFilter, + } + r.ctx = ctx + 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(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(r.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(options ...model.QueryOptions) SelectBuilder { + sq := r.sqlRepository.newSelect(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(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 +} + +// ResourceRepository interface implementation + +func (r *baseTagRepository) Count(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(sq) + + return r.count(sq, r.parseRestOptions(r.ctx, options...)) +} + +func (r *baseTagRepository) Read(id string) (interface{}, error) { + query := r.newSelect().Where(Eq{"id": id}) + var res model.Tag + err := r.queryOne(query, &res) + return &res, err +} + +func (r *baseTagRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + query := r.newSelect(r.parseRestOptions(r.ctx, options...)) + var res model.TagList + err := r.queryAll(query, &res) + return res, err +} + +func (r *baseTagRepository) EntityName() string { + return "tag" +} + +func (r *baseTagRepository) NewInstance() interface{} { + return model.Tag{} +} + +// Interface compliance check +var _ model.ResourceRepository = (*baseTagRepository)(nil) diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go new file mode 100644 index 0000000..77b9184 --- /dev/null +++ b/persistence/tag_library_filtering_test.go @@ -0,0 +1,263 @@ +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(adminCtx, 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(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 + } + + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + var opts rest.QueryOptions + if len(filters) > 0 { + opts = filters[0] + } + + tags, err := repo.ReadAll(opts) + Expect(err).ToNot(HaveOccurred()) + return tags.(model.TagList) + } + + // 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() + } + + tagRepo := NewTagRepository(ctx, GetDBXBuilder()) + repo := tagRepo.(model.ResourceRepository) + + count, err := repo.Count() + 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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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]interface{}{"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 new file mode 100644 index 0000000..5bb8b38 --- /dev/null +++ b/persistence/tag_repository.go @@ -0,0 +1,99 @@ +package persistence + +import ( + "context" + "fmt" + "slices" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type tagRepository struct { + *baseTagRepository +} + +func NewTagRepository(ctx context.Context, db dbx.Builder) model.TagRepository { + return &tagRepository{ + baseTagRepository: newBaseTagRepository(ctx, db, nil), // nil = no filter, works with all tags + } +} + +func (r *tagRepository) Add(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(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(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() 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(query) + log.Debug(r.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) purgeUnused() 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(del) + if err != nil { + return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err) + } + if c > 0 { + log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName) + } + return err +} + +var _ model.ResourceRepository = &tagRepository{} diff --git a/persistence/tag_repository_test.go b/persistence/tag_repository_test.go new file mode 100644 index 0000000..c3947a9 --- /dev/null +++ b/persistence/tag_repository_test.go @@ -0,0 +1,311 @@ +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 model.ResourceRepository + 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(ctx, GetDBXBuilder()) + repo = tagRepo + restRepo = tagRepo.(model.ResourceRepository) + + // 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(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(1, newTag) + Expect(err).ToNot(HaveOccurred()) + + // Verify tag was added + result, err := restRepo.Read(newTag.ID) + Expect(err).ToNot(HaveOccurred()) + resultTag := result.(*model.Tag) + Expect(resultTag.TagValue).To(Equal("experimental")) + + // Check count increased + count, err := restRepo.Count() + 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() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(Equal(int64(20))) // Still 20 tags + + err = repo.Add(1, duplicateTag) + Expect(err).ToNot(HaveOccurred()) // Should not error + + // Count should remain the same + count, err = restRepo.Count() + 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() + 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() + 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() + 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() + 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() + 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") + result, err := restRepo.Read(rockID) + Expect(err).ToNot(HaveOccurred()) + resultTag := result.(*model.Tag) + 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("non-existent-id") + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ReadAll", func() { + It("should return all tags from dataset", func() { + result, err := restRepo.ReadAll() + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + Expect(tags).To(HaveLen(20)) + }) + + It("should filter tags by partial value correctly", func() { + options := rest.QueryOptions{ + Filters: map[string]interface{}{"name": "%rock%"}, // Tags containing 'rock' + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + 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]interface{}{"name": "%e%"}, // Tags containing 'e' + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + 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]interface{}{"name": "%r%"}, // Tags containing 'r' + Sort: "name", + Order: "asc", + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + 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]interface{}{"name": "%r%"}, // Tags containing 'r' + Sort: "name", + Order: "desc", + } + result, err := restRepo.ReadAll(options) + Expect(err).ToNot(HaveOccurred()) + tags := result.(model.TagList) + 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 + })) + }) + }) + + Describe("EntityName", func() { + It("should return correct entity name", func() { + name := restRepo.EntityName() + Expect(name).To(Equal("tag")) + }) + }) + + Describe("NewInstance", func() { + It("should return new tag instance", func() { + instance := restRepo.NewInstance() + Expect(instance).To(BeAssignableToTypeOf(model.Tag{})) + }) + }) +}) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go new file mode 100644 index 0000000..125f575 --- /dev/null +++ b/persistence/transcoding_repository.go @@ -0,0 +1,112 @@ +package persistence + +import ( + "context" + "errors" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type transcodingRepository struct { + sqlRepository +} + +func NewTranscodingRepository(ctx context.Context, db dbx.Builder) model.TranscodingRepository { + r := &transcodingRepository{} + r.ctx = ctx + r.db = db + r.registerModel(&model.Transcoding{}, nil) + return r +} + +func (r *transcodingRepository) Get(id string) (*model.Transcoding, error) { + sel := r.newSelect().Columns("*").Where(Eq{"id": id}) + var res model.Transcoding + err := r.queryOne(sel, &res) + return &res, err +} + +func (r *transcodingRepository) CountAll(qo ...model.QueryOptions) (int64, error) { + return r.count(Select(), qo...) +} + +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(sel, &res) + return &res, err +} + +func (r *transcodingRepository) Put(t *model.Transcoding) error { + if !loggedUser(r.ctx).IsAdmin { + return rest.ErrPermissionDenied + } + _, err := r.put(t.ID, t) + return err +} + +func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, error) { + return r.count(Select(), r.parseRestOptions(r.ctx, options...)) +} + +func (r *transcodingRepository) Read(id string) (interface{}, error) { + return r.Get(id) +} + +func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") + res := model.Transcodings{} + err := r.queryAll(sel, &res) + return res, err +} + +func (r *transcodingRepository) EntityName() string { + return "transcoding" +} + +func (r *transcodingRepository) NewInstance() interface{} { + return &model.Transcoding{} +} + +func (r *transcodingRepository) Save(entity interface{}) (string, error) { + if !loggedUser(r.ctx).IsAdmin { + return "", rest.ErrPermissionDenied + } + t := entity.(*model.Transcoding) + id, err := r.put(t.ID, t) + if errors.Is(err, model.ErrNotFound) { + return "", rest.ErrNotFound + } + return id, err +} + +func (r *transcodingRepository) Update(id string, entity interface{}, cols ...string) error { + if !loggedUser(r.ctx).IsAdmin { + return rest.ErrPermissionDenied + } + t := entity.(*model.Transcoding) + t.ID = id + _, err := r.put(id, t) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +func (r *transcodingRepository) Delete(id string) error { + if !loggedUser(r.ctx).IsAdmin { + return rest.ErrPermissionDenied + } + err := r.delete(Eq{"id": id}) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +var _ model.TranscodingRepository = (*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 new file mode 100644 index 0000000..eddc504 --- /dev/null +++ b/persistence/transcoding_repository_test.go @@ -0,0 +1,96 @@ +package persistence + +import ( + "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 adminRepo model.TranscodingRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, regularUser) + repo = NewTranscodingRepository(ctx, GetDBXBuilder()) + + adminCtx := log.NewContext(GinkgoT().Context()) + adminCtx = request.WithUser(adminCtx, adminUser) + adminRepo = NewTranscodingRepository(adminCtx, GetDBXBuilder()) + }) + + AfterEach(func() { + // Clean up any transcoding created during the tests + tc, err := adminRepo.FindByFormat("test_format") + if err == nil { + err = adminRepo.(*transcodingRepository).Delete(tc.ID) + Expect(err).ToNot(HaveOccurred()) + } + }) + + Describe("Admin User", func() { + It("creates a new transcoding", func() { + base, err := adminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + + err = adminRepo.Put(&model.Transcoding{ID: "new", Name: "new", TargetFormat: "test_format", DefaultBitRate: 320, Command: "ffmpeg"}) + Expect(err).ToNot(HaveOccurred()) + + count, err := adminRepo.CountAll() + 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(adminRepo.Put(tr)).To(Succeed()) + tr.Name = "updated" + err := adminRepo.Put(tr) + Expect(err).ToNot(HaveOccurred()) + res, err := adminRepo.FindByFormat("test_format") + Expect(err).ToNot(HaveOccurred()) + Expect(res.Name).To(Equal("updated")) + }) + + It("deletes a transcoding", func() { + err := adminRepo.Put(&model.Transcoding{ID: "to-delete", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 256, Command: "ffmpeg"}) + Expect(err).ToNot(HaveOccurred()) + err = adminRepo.(*transcodingRepository).Delete("to-delete") + Expect(err).ToNot(HaveOccurred()) + _, err = adminRepo.Get("to-delete") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + }) + + Describe("Regular User", func() { + It("fails to create", func() { + err := repo.Put(&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(adminRepo.Put(tr)).To(Succeed()) + + tr.Name = "bad" + err := repo.Put(tr) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + //_ = adminRepo.(*transcodingRepository).Delete("updreg") + }) + + It("fails to delete", func() { + tr := &model.Transcoding{ID: "delreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + err := repo.(*transcodingRepository).Delete("delreg") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + //_ = adminRepo.(*transcodingRepository).Delete("delreg") + }) + }) +}) diff --git a/persistence/user_props_repository.go b/persistence/user_props_repository.go new file mode 100644 index 0000000..9307385 --- /dev/null +++ b/persistence/user_props_repository.go @@ -0,0 +1,63 @@ +package persistence + +import ( + "context" + "errors" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/model" + "github.com/pocketbase/dbx" +) + +type userPropsRepository struct { + sqlRepository +} + +func NewUserPropsRepository(ctx context.Context, db dbx.Builder) model.UserPropsRepository { + r := &userPropsRepository{} + r.ctx = ctx + r.db = db + r.tableName = "user_props" + return r +} + +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(update) + if err != nil { + return err + } + if count > 0 { + return nil + } + insert := Insert(r.tableName).Columns("user_id", "key", "value").Values(userId, key, value) + _, err = r.executeSQL(insert) + return err +} + +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(sel, &resp) + if err != nil { + return "", err + } + return resp.Value, nil +} + +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 + } + if err != nil { + return defaultValue, err + } + return value, nil +} + +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 new file mode 100644 index 0000000..7baa8f6 --- /dev/null +++ b/persistence/user_repository.go @@ -0,0 +1,475 @@ +package persistence + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "strings" + "sync" + "time" + + . "github.com/Masterminds/squirrel" + "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/id" + "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 }) +} + +var ( + once sync.Once + encKey []byte +) + +func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository { + r := &userRepository{} + r.ctx = ctx + r.db = db + r.tableName = "user" + r.registerModel(&model.User{}, map[string]filterFunc{ + "id": idFilter(r.tableName), + "password": invalidFilter(ctx), + "name": r.withTableName(startsWithFilter), + }) + once.Do(func() { + _ = r.initPasswordEncryptionKey() + }) + return r +} + +// selectUserWithLibraries returns a SelectBuilder that includes library information +func (r *userRepository) selectUserWithLibraries(options ...model.QueryOptions) SelectBuilder { + return r.newSelect(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) Get(id string) (*model.User, error) { + sel := r.selectUserWithLibraries().Where(Eq{"user.id": id}) + var res dbUser + err := r.queryOne(sel, &res) + if err != nil { + return nil, err + } + return res.User, nil +} + +func (r *userRepository) GetAll(options ...model.QueryOptions) (model.Users, error) { + sel := r.selectUserWithLibraries(options...) + var res dbUsers + err := r.queryAll(sel, &res) + if err != nil { + return nil, err + } + return res.toModels(), nil +} + +func (r *userRepository) Put(u *model.User) error { + if u.ID == "" { + u.ID = id.NewRandom() + } + u.UpdatedAt = time.Now() + if u.NewPassword != "" { + _ = r.encryptPassword(u) + } + values, err := toSQLArgs(*u) + if err != nil { + return fmt.Errorf("error converting user to SQL args: %w", err) + } + delete(values, "current_password") + + // Save/update the user + update := Update(r.tableName).Where(Eq{"id": u.ID}).SetMap(values) + count, err := r.executeSQL(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(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(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(sql); err != nil { + return fmt.Errorf("failed to assign default libraries to new user: %w", err) + } + } + + return nil +} + +func (r *userRepository) FindFirstAdmin() (*model.User, error) { + sel := r.selectUserWithLibraries(model.QueryOptions{Sort: "updated_at", Max: 1}).Where(Eq{"user.is_admin": true}) + var usr dbUser + err := r.queryOne(sel, &usr) + if err != nil { + return nil, err + } + return usr.User, nil +} + +func (r *userRepository) FindByUsername(username string) (*model.User, error) { + sel := r.selectUserWithLibraries().Where(Expr("user.user_name = ? COLLATE NOCASE", username)) + var usr dbUser + err := r.queryOne(sel, &usr) + if err != nil { + return nil, err + } + return usr.User, nil +} + +func (r *userRepository) FindByUsernameWithPassword(username string) (*model.User, error) { + usr, err := r.FindByUsername(username) + if err != nil { + return nil, err + } + _ = r.decryptPassword(usr) + return usr, nil +} + +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(upd) + return err +} + +func (r *userRepository) Count(options ...rest.QueryOptions) (int64, error) { + usr := loggedUser(r.ctx) + if !usr.IsAdmin { + return 0, rest.ErrPermissionDenied + } + return r.CountAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *userRepository) Read(id string) (any, error) { + usr := loggedUser(r.ctx) + if !usr.IsAdmin && usr.ID != id { + return nil, rest.ErrPermissionDenied + } + usr, err := r.Get(id) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return usr, err +} + +func (r *userRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + usr := loggedUser(r.ctx) + if !usr.IsAdmin { + return nil, rest.ErrPermissionDenied + } + return r.GetAll(r.parseRestOptions(r.ctx, options...)) +} + +func (r *userRepository) EntityName() string { + return "user" +} + +func (r *userRepository) NewInstance() any { + return &model.User{} +} + +func (r *userRepository) Save(entity any) (string, error) { + usr := loggedUser(r.ctx) + if !usr.IsAdmin { + return "", rest.ErrPermissionDenied + } + u := entity.(*model.User) + if err := validateUsernameUnique(r, u); err != nil { + return "", err + } + err := r.Put(u) + if err != nil { + return "", err + } + return u.ID, err +} + +func (r *userRepository) Update(id string, entity any, _ ...string) error { + u := entity.(*model.User) + u.ID = id + usr := loggedUser(r.ctx) + if !usr.IsAdmin && usr.ID != u.ID { + return rest.ErrPermissionDenied + } + if !usr.IsAdmin { + if !conf.Server.EnableUserEditing { + return rest.ErrPermissionDenied + } + u.IsAdmin = false + u.UserName = usr.UserName + } + + // 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(usr); err != nil { + return err + } + if err := validatePasswordChange(u, usr); err != nil { + return err + } + if err := validateUsernameUnique(r, u); err != nil { + return err + } + err := r.Put(u) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +func validatePasswordChange(newUser *model.User, logged *model.User) error { + err := &rest.ValidationError{Errors: map[string]string{}} + if logged.IsAdmin && newUser.ID != logged.ID { + return nil + } + if newUser.NewPassword == "" { + if newUser.CurrentPassword == "" { + return nil + } + err.Errors["password"] = "ra.validation.required" + } + + if !strings.HasPrefix(logged.Password, consts.PasswordAutogenPrefix) { + if newUser.CurrentPassword == "" { + err.Errors["currentPassword"] = "ra.validation.required" + } + if newUser.CurrentPassword != logged.Password { + err.Errors["currentPassword"] = "ra.validation.passwordDoesNotMatch" + } + } + if len(err.Errors) > 0 { + return err + } + return nil +} + +func validateUsernameUnique(r model.UserRepository, u *model.User) error { + usr, err := r.FindByUsername(u.UserName) + if errors.Is(err, model.ErrNotFound) { + return nil + } + if err != nil { + return err + } + if usr.ID != u.ID { + return &rest.ValidationError{Errors: map[string]string{"userName": "ra.validation.unique"}} + } + return nil +} + +func (r *userRepository) Delete(id string) error { + usr := loggedUser(r.ctx) + if !usr.IsAdmin { + return rest.ErrPermissionDenied + } + err := r.delete(Eq{"id": id}) + if errors.Is(err, model.ErrNotFound) { + return rest.ErrNotFound + } + return err +} + +func keyTo32Bytes(input string) []byte { + data := sha256.Sum256([]byte(input)) + return data[0:] +} + +func (r *userRepository) initPasswordEncryptionKey() error { + encKey = keyTo32Bytes(consts.DefaultEncryptionKey) + if conf.Server.PasswordEncryptionKey == "" { + return nil + } + + key := keyTo32Bytes(conf.Server.PasswordEncryptionKey) + keySum := fmt.Sprintf("%x", sha256.Sum256(key)) + + props := NewPropertyRepository(r.ctx, r.db) + savedKeySum, err := props.Get(consts.PasswordsEncryptedKey) + + // If passwords are already encrypted + if err == nil { + if savedKeySum != keySum { + log.Error("Password Encryption Key changed! Users won't be able to login!") + return errors.New("passwordEncryptionKey changed") + } + encKey = key + return nil + } + + // if not, try to re-encrypt all current passwords with new encryption key, + // assuming they were encrypted with the DefaultEncryptionKey + sql := r.newSelect().Columns("id", "user_name", "password") + users := model.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(users); err != nil { + return err + } + encKey = key + for i := range users { + u := users[i] + u.NewPassword = u.Password + if err := r.encryptPassword(&u); err == nil { + upd := Update(r.tableName).Set("password", u.NewPassword).Where(Eq{"id": u.ID}) + _, err = r.executeSQL(upd) + if err != nil { + log.Error("Password NOT encrypted! This may cause problems!", "user", u.UserName, "id", u.ID, err) + } else { + log.Warn("Password encrypted successfully", "user", u.UserName, "id", u.ID) + } + } + } + + 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 + } + return nil +} + +// encrypts u.NewPassword +func (r *userRepository) encryptPassword(u *model.User) error { + encPassword, err := utils.Encrypt(r.ctx, encKey, u.NewPassword) + if err != nil { + log.Error(r.ctx, "Error encrypting user's password", "user", u.UserName, err) + return err + } + u.NewPassword = encPassword + return nil +} + +// decrypts u.Password +func (r *userRepository) decryptPassword(u *model.User) error { + plaintext, err := utils.Decrypt(r.ctx, encKey, u.Password) + if err != nil { + log.Error(r.ctx, "Error decrypting user's password", "user", u.UserName, err) + return err + } + u.Password = plaintext + return nil +} + +func (r *userRepository) decryptAllPasswords(users model.Users) error { + for i := range users { + if err := r.decryptPassword(&users[i]); err != nil { + return err + } + } + return nil +} + +// Library association methods + +func (r *userRepository) GetUserLibraries(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(sel, &res) + return res, err +} + +func (r *userRepository) SetUserLibraries(userID string, libraryIDs []int) error { + // Remove existing associations + delSql := Delete("user_library").Where(Eq{"user_id": userID}) + if _, err := r.executeSQL(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(insert) + return err + } + return nil +} + +var _ model.UserRepository = (*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 new file mode 100644 index 0000000..8abbf76 --- /dev/null +++ b/persistence/user_repository_test.go @@ -0,0 +1,573 @@ +package persistence + +import ( + "context" + "errors" + "slices" + + "github.com/Masterminds/squirrel" + "github.com/deluan/rest" + "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/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("UserRepository", func() { + var repo model.UserRepository + + BeforeEach(func() { + repo = NewUserRepository(log.NewContext(GinkgoT().Context()), GetDBXBuilder()) + }) + + Describe("Put/Get/FindByUsername", func() { + usr := model.User{ + ID: "123", + UserName: "AdMiN", + Name: "Admin", + Email: "admin@admin.com", + NewPassword: "wordpass", + IsAdmin: true, + } + It("saves the user to the DB", func() { + Expect(repo.Put(&usr)).To(BeNil()) + }) + It("returns the newly created user", func() { + 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("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("aDmIn") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Name).To(Equal("Admin")) + Expect(actual.Password).To(Equal("wordpass")) + }) + It("updates the name and keep the same password", func() { + usr.Name = "Jane Doe" + usr.NewPassword = "" + Expect(repo.Put(&usr)).To(BeNil()) + + 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(&usr)).To(BeNil()) + + actual, err := repo.FindByUsernameWithPassword("admin") + Expect(err).ToNot(HaveOccurred()) + Expect(actual.Password).To(Equal("newpass")) + }) + }) + + Describe("validatePasswordChange", func() { + var loggedUser *model.User + + BeforeEach(func() { + loggedUser = &model.User{ID: "1", UserName: "logan"} + }) + + It("does nothing if passwords are not specified", func() { + user := &model.User{ID: "2", UserName: "johndoe"} + err := validatePasswordChange(user, loggedUser) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("Autogenerated password (used with Reverse Proxy Authentication)", func() { + var user model.User + BeforeEach(func() { + loggedUser.IsAdmin = false + loggedUser.Password = consts.PasswordAutogenPrefix + id.NewRandom() + }) + It("does nothing if passwords are not specified", func() { + user = *loggedUser + err := validatePasswordChange(&user, loggedUser) + Expect(err).ToNot(HaveOccurred()) + }) + It("does not requires currentPassword for regular user", func() { + user = *loggedUser + user.CurrentPassword = "" + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + Expect(err).ToNot(HaveOccurred()) + }) + It("does not requires currentPassword for admin", func() { + loggedUser.IsAdmin = true + user = *loggedUser + user.CurrentPassword = "" + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("Logged User is admin", func() { + BeforeEach(func() { + loggedUser.IsAdmin = true + }) + It("can change other user's passwords without currentPassword", func() { + user := &model.User{ID: "2", UserName: "johndoe"} + user.NewPassword = "new" + err := validatePasswordChange(user, loggedUser) + Expect(err).ToNot(HaveOccurred()) + }) + It("requires currentPassword to change its own", func() { + user := *loggedUser + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + var verr *rest.ValidationError + errors.As(err, &verr) + Expect(verr.Errors).To(HaveLen(1)) + Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required")) + }) + It("does not allow to change password to empty string", func() { + loggedUser.Password = "abc123" + user := *loggedUser + user.CurrentPassword = "abc123" + err := validatePasswordChange(&user, loggedUser) + var verr *rest.ValidationError + errors.As(err, &verr) + Expect(verr.Errors).To(HaveLen(1)) + Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required")) + }) + It("fails if currentPassword does not match", func() { + loggedUser.Password = "abc123" + user := *loggedUser + user.CurrentPassword = "current" + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + var verr *rest.ValidationError + errors.As(err, &verr) + Expect(verr.Errors).To(HaveLen(1)) + Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch")) + }) + It("can change own password if requirements are met", func() { + loggedUser.Password = "abc123" + user := *loggedUser + user.CurrentPassword = "abc123" + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("Logged User is a regular user", func() { + BeforeEach(func() { + loggedUser.IsAdmin = false + }) + It("requires currentPassword", func() { + user := *loggedUser + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + var verr *rest.ValidationError + errors.As(err, &verr) + Expect(verr.Errors).To(HaveLen(1)) + Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.required")) + }) + It("does not allow to change password to empty string", func() { + loggedUser.Password = "abc123" + user := *loggedUser + user.CurrentPassword = "abc123" + err := validatePasswordChange(&user, loggedUser) + var verr *rest.ValidationError + errors.As(err, &verr) + Expect(verr.Errors).To(HaveLen(1)) + Expect(verr.Errors).To(HaveKeyWithValue("password", "ra.validation.required")) + }) + It("fails if currentPassword does not match", func() { + loggedUser.Password = "abc123" + user := *loggedUser + user.CurrentPassword = "current" + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + var verr *rest.ValidationError + errors.As(err, &verr) + Expect(verr.Errors).To(HaveLen(1)) + Expect(verr.Errors).To(HaveKeyWithValue("currentPassword", "ra.validation.passwordDoesNotMatch")) + }) + It("can change own password if requirements are met", func() { + loggedUser.Password = "abc123" + user := *loggedUser + user.CurrentPassword = "abc123" + user.NewPassword = "new" + err := validatePasswordChange(&user, loggedUser) + Expect(err).ToNot(HaveOccurred()) + }) + }) + }) + + 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(existingUser) + Expect(err).ToNot(HaveOccurred()) + }) + It("allows unique usernames", func() { + var newUser = &model.User{ID: "2", UserName: "unique_username"} + 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(repo, newUser) + var verr *rest.ValidationError + isValidationError := errors.As(err, &verr) + + Expect(isValidationError).To(BeTrue()) + Expect(verr.Errors).To(HaveKeyWithValue("userName", "ra.validation.unique")) + }) + It("returns generic error if repository call fails", func() { + repo.Error = errors.New("fake error") + + var newUser = &model.User{ID: "2", UserName: "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(&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(log.NewContext(context.TODO()), GetDBXBuilder()) + Expect(libRepo.Put(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) + }) + + AfterEach(func() { + // Clean up user-library associations to ensure test isolation + _ = repo.SetUserLibraries(userID, []int{}) + + // Clean up test libraries to ensure isolation between test groups + libRepo := NewLibraryRepository(log.NewContext(context.TODO()), GetDBXBuilder()) + _ = libRepo.(*libraryRepository).delete(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("non-existent-user") + Expect(err).ToNot(HaveOccurred()) + Expect(libraries).To(HaveLen(0)) + }) + + It("returns user's associated libraries", func() { + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + Expect(err).ToNot(HaveOccurred()) + + libraries, err := repo.GetUserLibraries(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(userID, libraryIDs) + Expect(err).ToNot(HaveOccurred()) + + libraries, err := repo.GetUserLibraries(userID) + Expect(err).ToNot(HaveOccurred()) + Expect(libraries).To(HaveLen(2)) + }) + + It("replaces existing associations", func() { + // Set initial associations + err := repo.SetUserLibraries(userID, []int{library1.ID, library2.ID}) + Expect(err).ToNot(HaveOccurred()) + + // Replace with just one library + err = repo.SetUserLibraries(userID, []int{library1.ID}) + Expect(err).ToNot(HaveOccurred()) + + libraries, err := repo.GetUserLibraries(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(userID, []int{library1.ID, library2.ID}) + Expect(err).ToNot(HaveOccurred()) + + // Remove all + err = repo.SetUserLibraries(userID, []int{}) + Expect(err).ToNot(HaveOccurred()) + + libraries, err := repo.GetUserLibraries(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(log.NewContext(context.TODO()), GetDBXBuilder()) + + // Count initial libraries + existingLibs, err := libRepo.GetAll() + 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(&library1)).To(BeNil()) + Expect(libRepo.Put(&library2)).To(BeNil()) + }) + + AfterEach(func() { + // Clean up test libraries and their associations + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + + // Clean up user-library associations for these test libraries + _, _ = repo.(*userRepository).executeSQL(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(&adminUser) + Expect(err).ToNot(HaveOccurred()) + + // Admin should automatically have access to all libraries (including existing ones) + libraries, err := repo.GetUserLibraries(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(®ularUser) + Expect(err).ToNot(HaveOccurred()) + + // Give them access to just one library + err = repo.SetUserLibraries(regularUser.ID, []int{library1.ID}) + Expect(err).ToNot(HaveOccurred()) + + // Promote to admin + regularUser.IsAdmin = true + err = repo.Put(®ularUser) + Expect(err).ToNot(HaveOccurred()) + + // Should now have access to all libraries (including existing ones) + libraries, err := repo.GetUserLibraries(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(®ularUser) + Expect(err).ToNot(HaveOccurred()) + + // Regular user should be assigned to default libraries (library ID 1 from migration) + libraries, err := repo.GetUserLibraries(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(log.NewContext(context.TODO()), 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(&library1)).To(BeNil()) + Expect(libRepo.Put(&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(&testUser)).To(BeNil()) + + // Assign libraries to user + Expect(repo.SetUserLibraries(testUser.ID, []int{library1.ID, library2.ID})).To(BeNil()) + }) + + AfterEach(func() { + // Clean up test libraries and their associations + _ = libRepo.(*libraryRepository).delete(squirrel.Eq{"id": []int{library1.ID, library2.ID}}) + _ = repo.(*userRepository).delete(squirrel.Eq{"id": testUser.ID}) + + // Clean up user-library associations for these test libraries + _, _ = repo.(*userRepository).executeSQL(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(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() + 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(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(&userWithoutLibs)).To(BeNil()) + defer func() { _ = repo.(*userRepository).delete(squirrel.Eq{"id": userWithoutLibs.ID}) }() + + user, err := repo.Get(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("filters", func() { + It("qualifies id filter with table name", func() { + r := repo.(*userRepository) + qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}}) + sel := r.selectUserWithLibraries(qo) + query, _, err := r.toSQL(sel) + Expect(err).NotTo(HaveOccurred()) + Expect(query).To(ContainSubstring("user.id = {:p0}")) + }) + }) +}) diff --git a/plugins/README.md b/plugins/README.md new file mode 100644 index 0000000..100230c --- /dev/null +++ b/plugins/README.md @@ -0,0 +1,1760 @@ +# Navidrome Plugin System + +## Overview + +Navidrome's plugin system is a WebAssembly (WASM) based extension mechanism that enables developers to expand Navidrome's functionality without modifying the core codebase. The plugin system supports several capabilities that can be implemented by plugins: + +1. **MetadataAgent** - For fetching artist and album information, images, etc. +2. **Scrobbler** - For implementing scrobbling functionality with external services +3. **SchedulerCallback** - For executing code after a specified delay or on a recurring schedule +4. **WebSocketCallback** - For interacting with WebSocket endpoints and handling WebSocket events +5. **LifecycleManagement** - For plugin initialization and configuration (one-time `OnInit` only; not invoked per-request) + +## Plugin Architecture + +The plugin system is built on the following key components: + +### 1. Plugin Manager + +The `Manager` (implemented in `plugins/manager.go`) is the core component that: + +- Scans for plugins in the configured plugins directory +- Loads and compiles plugins +- Provides access to loaded plugins through capability-specific interfaces + +### 2. Plugin Protocol + +Plugins communicate with Navidrome using Protocol Buffers (protobuf) over a WASM runtime. The protocol is defined in `plugins/api/api.proto` which specifies the capabilities and messages that plugins can implement. + +### 3. Plugin Adapters + +Adapters bridge between the plugin API and Navidrome's internal interfaces: + +- `wasmMediaAgent` adapts `MetadataAgent` to the internal `agents.Interface` +- `wasmScrobblerPlugin` adapts `Scrobbler` to the internal `scrobbler.Scrobbler` +- `wasmSchedulerCallback` adapts `SchedulerCallback` to the internal `SchedulerCallback` + +* **Plugin Instance Pooling**: Instances are managed in an internal pool (default 8 max, 1m TTL). +* **WASM Compilation & Caching**: Modules are pre-compiled concurrently (max 2) and cached in `[CacheFolder]/plugins`, reducing startup time. The compilation timeout can be configured via `DevPluginCompilationTimeout` in development. + +### 4. Host Services + +Navidrome provides host services that plugins can call to access functionality like HTTP requests and scheduling. +These services are defined in `plugins/host/` and implemented in corresponding host files: + +- HTTP service (in `plugins/host_http.go`) for making external requests +- Scheduler service (in `plugins/host_scheduler.go`) for scheduling timed events +- Config service (in `plugins/host_config.go`) for accessing plugin-specific configuration +- WebSocket service (in `plugins/host_websocket.go`) for WebSocket communication +- Cache service (in `plugins/host_cache.go`) for TTL-based plugin caching +- Artwork service (in `plugins/host_artwork.go`) for generating public artwork URLs +- SubsonicAPI service (in `plugins/host_subsonicapi.go`) for accessing Navidrome's Subsonic API + +### Available Host Services + +The following host services are available to plugins: + +#### HttpService + +```protobuf +// HTTP methods available to plugins +service HttpService { + rpc Get(HttpRequest) returns (HttpResponse); + rpc Post(HttpRequest) returns (HttpResponse); + rpc Put(HttpRequest) returns (HttpResponse); + rpc Delete(HttpRequest) returns (HttpResponse); + rpc Patch(HttpRequest) returns (HttpResponse); + rpc Head(HttpRequest) returns (HttpResponse); + rpc Options(HttpRequest) returns (HttpResponse); +} +``` + +#### ConfigService + +```protobuf +service ConfigService { + rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse); +} +``` + +The ConfigService allows plugins to access plugin-specific configuration. See the [config.proto](host/config/config.proto) file for the full API. + +#### ArtworkService + +```protobuf +service ArtworkService { + rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); +} +``` + +Provides methods to get public URLs for artwork images: + +- `GetArtistUrl(id string, size int) string`: Returns a public URL for an artist's artwork +- `GetAlbumUrl(id string, size int) string`: Returns a public URL for an album's artwork +- `GetTrackUrl(id string, size int) string`: Returns a public URL for a track's artwork + +The `size` parameter is optional (use 0 for original size). The URLs returned are based on the server's ShareURL configuration. + +Example: + +```go +url := artwork.GetArtistUrl("123", 300) // Get artist artwork URL with size 300px +url := artwork.GetAlbumUrl("456", 0) // Get album artwork URL in original size +``` + +#### CacheService + +```protobuf +service CacheService { + // Set a string value in the cache + rpc SetString(SetStringRequest) returns (SetResponse); + + // Get a string value from the cache + rpc GetString(GetRequest) returns (GetStringResponse); + + // Set an integer value in the cache + rpc SetInt(SetIntRequest) returns (SetResponse); + + // Get an integer value from the cache + rpc GetInt(GetRequest) returns (GetIntResponse); + + // Set a float value in the cache + rpc SetFloat(SetFloatRequest) returns (SetResponse); + + // Get a float value from the cache + rpc GetFloat(GetRequest) returns (GetFloatResponse); + + // Set a byte slice value in the cache + rpc SetBytes(SetBytesRequest) returns (SetResponse); + + // Get a byte slice value from the cache + rpc GetBytes(GetRequest) returns (GetBytesResponse); + + // Remove a value from the cache + rpc Remove(RemoveRequest) returns (RemoveResponse); + + // Check if a key exists in the cache + rpc Has(HasRequest) returns (HasResponse); +} +``` + +The CacheService provides a TTL-based cache for plugins. Each plugin gets its own isolated cache instance. By default, cached items expire after 24 hours unless a custom TTL is specified. + +Key features: + +- **Isolated Caches**: Each plugin has its own cache namespace, so different plugins can use the same key names without conflicts +- **Typed Values**: Store and retrieve values with their proper types (string, int64, float64, or byte slice) +- **Configurable TTL**: Set custom expiration times per item, or use the default 24-hour TTL +- **Type Safety**: The system handles type checking, returning "not exists" if there's a type mismatch + +Example usage: + +```go +// Store a string value with default TTL (24 hours) +cacheService.SetString(ctx, &cache.SetStringRequest{ + Key: "user_preference", + Value: "dark_mode", +}) + +// Store an integer with custom TTL (5 minutes) +cacheService.SetInt(ctx, &cache.SetIntRequest{ + Key: "api_call_count", + Value: 42, + TtlSeconds: 300, // 5 minutes +}) + +// Retrieve a value +resp, err := cacheService.GetString(ctx, &cache.GetRequest{ + Key: "user_preference", +}) +if err != nil { + // Handle error +} +if resp.Exists { + // Use resp.Value +} else { + // Key doesn't exist or has expired +} + +// Check if a key exists +hasResp, err := cacheService.Has(ctx, &cache.HasRequest{ + Key: "api_call_count", +}) +if hasResp.Exists { + // Key exists and hasn't expired +} + +// Remove a value +cacheService.Remove(ctx, &cache.RemoveRequest{ + Key: "user_preference", +}) +``` + +See the [cache.proto](host/cache/cache.proto) file for the full API definition. + +#### SchedulerService + +The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks, as well as accessing current time information. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API. + +```protobuf +service SchedulerService { + // One-time event scheduling + rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse); + + // Recurring event scheduling + rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse); + + // Cancel any scheduled job + rpc CancelSchedule(CancelRequest) returns (CancelResponse); + + // Get current time in multiple formats + rpc TimeNow(TimeNowRequest) returns (TimeNowResponse); +} +``` + +**Key Features:** + +- **One-time scheduling**: Schedule a callback to be executed once after a specified delay. +- **Recurring scheduling**: Schedule a callback to be executed repeatedly according to a cron expression. +- **Current time access**: Get the current time in standardized formats for time-based operations. + +**TimeNow Function:** + +The `TimeNow` function returns the current time in three formats: + +```protobuf +message TimeNowResponse { + string rfc3339_nano = 1; // RFC3339 format with nanosecond precision + int64 unix_milli = 2; // Unix timestamp in milliseconds + string local_time_zone = 3; // Local timezone name (e.g., "UTC", "America/New_York") +} +``` + +This allows plugins to: + +- Get high-precision timestamps for logging and event correlation +- Perform time-based calculations using Unix timestamps +- Handle timezone-aware operations by knowing the server's local timezone + +Example usage: + +```go +// Get current time information +timeResp, err := scheduler.TimeNow(ctx, &scheduler.TimeNowRequest{}) +if err != nil { + return err +} + +// Use the different time formats +timestamp := timeResp.Rfc3339Nano // "2024-01-15T10:30:45.123456789Z" +unixMs := timeResp.UnixMilli // 1705312245123 +timezone := timeResp.LocalTimeZone // "UTC" +``` + +Plugins using this service must implement the `SchedulerCallback` interface: + +```protobuf +service SchedulerCallback { + rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse); +} +``` + +The `IsRecurring` field in the request allows plugins to differentiate between one-time and recurring callbacks. + +#### WebSocketService + +The WebSocketService enables plugins to connect to and interact with WebSocket endpoints. See the [websocket.proto](host/websocket/websocket.proto) file for the full API. + +```protobuf +service WebSocketService { + // Connect to a WebSocket endpoint + rpc Connect(ConnectRequest) returns (ConnectResponse); + + // Send a text message + rpc SendText(SendTextRequest) returns (SendTextResponse); + + // Send binary data + rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse); + + // Close a connection + rpc Close(CloseRequest) returns (CloseResponse); +} +``` + +- **Connect**: Establish a WebSocket connection to a specified URL with optional headers +- **SendText**: Send text messages over an established connection +- **SendBinary**: Send binary data over an established connection +- **Close**: Close a WebSocket connection with optional close code and reason + +Plugins using this service must implement the `WebSocketCallback` interface to handle incoming messages and connection events: + +```protobuf +service WebSocketCallback { + rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse); + rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse); + rpc OnError(OnErrorRequest) returns (OnErrorResponse); + rpc OnClose(OnCloseRequest) returns (OnCloseResponse); +} +``` + +Example usage: + +```go +// Connect to a WebSocket server +connectResp, err := websocket.Connect(ctx, &websocket.ConnectRequest{ + Url: "wss://example.com/ws", + Headers: map[string]string{"Authorization": "Bearer token"}, + ConnectionId: "my-connection-id", +}) +if err != nil { + return err +} + +// Send a text message +_, err = websocket.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: "my-connection-id", + Message: "Hello WebSocket", +}) + +// Send binary data +_, err = websocket.SendBinary(ctx, &websocket.SendBinaryRequest{ + ConnectionId: "my-connection-id", + Data: []byte{0x01, 0x02, 0x03}, +}) + +// Close the connection when done +_, err = websocket.Close(ctx, &websocket.CloseRequest{ + ConnectionId: "my-connection-id", + Code: 1000, // Normal closure + Reason: "Done", +}) +``` + +#### SubsonicAPIService + +```protobuf +service SubsonicAPIService { + rpc Call(CallRequest) returns (CallResponse); +} +``` + +The SubsonicAPIService provides plugins with access to Navidrome's Subsonic API endpoints. This allows plugins to query and interact with Navidrome's music library data using the same API that external Subsonic clients use. + +Key features: + +- **Library Access**: Query artists, albums, tracks, playlists, and other music library data +- **Search Functionality**: Search across the music library using various criteria +- **Metadata Retrieval**: Get detailed information about music items including ratings, play counts, etc. +- **Authentication Handled**: The service automatically handles authentication using internal auth context +- **JSON Responses**: All responses are returned as JSON strings for easy parsing + +**Important Security Notes:** + +- Plugins must specify a username via the `u` parameter in the URL - this determines which user's library view and permissions apply +- The service uses internal authentication, so plugins don't need to provide passwords or API keys +- All Subsonic API security and access controls apply based on the specified user + +Example usage: + +```go +// Get ping response to test connectivity +resp, err := subsonicAPI.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", +}) +if err != nil { + return err +} +// resp.Json contains the JSON response + +// Search for artists +resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/search3?u=admin&query=Beatles&artistCount=10", +}) + +// Get album details +resp, err = subsonicAPI.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/getAlbum?u=admin&id=123", +}) + +// Check for errors +if resp.Error != "" { + // Handle error - could be missing parameters, invalid user, etc. + log.Printf("SubsonicAPI error: %s", resp.Error) +} +``` + +**Common URL Patterns:** + +- `/rest/ping?u=USERNAME` - Test API connectivity +- `/rest/search3?u=USERNAME&query=TERM` - Search library +- `/rest/getArtists?u=USERNAME` - Get all artists +- `/rest/getAlbum?u=USERNAME&id=ID` - Get album details +- `/rest/getPlaylists?u=USERNAME` - Get user playlists + +**Required Parameters:** + +- `u` (username): Required for all requests - determines user context and permissions +- `f=json`: Recommended to get JSON responses (easier to parse than XML) + +The service accepts standard Subsonic API endpoints and parameters. Refer to the [Subsonic API documentation](http://www.subsonic.org/pages/api.jsp) for complete endpoint details, but note that authentication parameters (`p`, `t`, `s`, `c`, `v`) are handled automatically. + +See the [subsonicapi.proto](host/subsonicapi/subsonicapi.proto) file for the full API definition. + +## Plugin Permission System + +Navidrome implements a permission-based security system that controls which host services plugins can access. This system enforces security at load-time by only making authorized services available to plugins in their WebAssembly runtime environment. + +### How Permissions Work + +The permission system follows a **secure-by-default** approach: + +1. **Default Behavior**: Plugins have access to **no host services** unless explicitly declared +2. **Load-time Enforcement**: Only services listed in a plugin's permissions are loaded into its WASM runtime +3. **Runtime Security**: Unauthorized services are completely unavailable - attempts to call them result in "function not exported" errors + +This design ensures that even if malicious code tries to access unauthorized services, the calls will fail because the functions simply don't exist in the plugin's runtime environment. + +### Permission Syntax + +Permissions are declared in the plugin's `manifest.json` file using the `permissions` field as an object: + +```json +{ + "name": "my-plugin", + "author": "Plugin Developer", + "version": "1.0.0", + "description": "A plugin that fetches data and caches results", + "website": "https://github.com/plugindeveloper/my-plugin", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch metadata from external APIs", + "allowedUrls": { + "https://api.musicbrainz.org": ["GET"], + "https://coverartarchive.org": ["GET"] + }, + "allowLocalNetwork": false + }, + "cache": { + "reason": "To cache API responses and reduce rate limiting" + }, + "subsonicapi": { + "reason": "To query music library for artist and album information", + "allowedUsernames": ["metadata-user"], + "allowAdmins": false + } + } +} +``` + +Each permission is represented as a key in the permissions object. The value must be an object containing a `reason` field that explains why the permission is needed. + +**Important**: Some permissions require additional configuration fields: + +- **`http`**: Requires `allowedUrls` object mapping URL patterns to allowed HTTP methods, and optional `allowLocalNetwork` boolean +- **`websocket`**: Requires `allowedUrls` array of WebSocket URL patterns, and optional `allowLocalNetwork` boolean +- **`subsonicapi`**: Requires `reason` field, with optional `allowedUsernames` array and `allowAdmins` boolean for fine-grained access control +- **`config`**, **`cache`**, **`scheduler`**, **`artwork`**: Only require the `reason` field + +**Security Benefits of Required Reasons:** + +- **Transparency**: Users can see exactly what each plugin will do with its permissions +- **Security Auditing**: Makes it easier to identify suspicious or overly broad permission requests +- **Developer Accountability**: Forces plugin authors to justify each permission they request +- **Trust Building**: Clear explanations help users make informed decisions about plugin installation + +If no permissions are needed, use an empty permissions object: `"permissions": {}`. + +### Available Permissions + +The following permission keys correspond to host services: + +| Permission | Host Service | Description | Required Fields | +| ------------- | ------------------ | -------------------------------------------------- | ----------------------------------------------------- | +| `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` | +| `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` | +| `cache` | CacheService | Store and retrieve cached data with TTL | `reason` | +| `config` | ConfigService | Access Navidrome configuration values | `reason` | +| `scheduler` | SchedulerService | Schedule one-time and recurring tasks | `reason` | +| `artwork` | ArtworkService | Generate public URLs for artwork images | `reason` | +| `subsonicapi` | SubsonicAPIService | Access Navidrome's Subsonic API endpoints | `reason`, optional: `allowedUsernames`, `allowAdmins` | + +#### HTTP Permission Structure + +HTTP permissions require explicit URL whitelisting for security: + +```json +{ + "http": { + "reason": "To fetch artist data from MusicBrainz and album covers from Cover Art Archive", + "allowedUrls": { + "https://musicbrainz.org/ws/2/*": ["GET"], + "https://coverartarchive.org/*": ["GET"], + "https://api.example.com/submit": ["POST"] + }, + "allowLocalNetwork": false + } +} +``` + +**Fields:** + +- `reason` (required): Explanation of why HTTP access is needed +- `allowedUrls` (required): Object mapping URL patterns to allowed HTTP methods +- `allowLocalNetwork` (optional, default false): Whether to allow requests to localhost/private IPs + +**URL Pattern Matching:** + +- Exact URLs: `"https://api.example.com/endpoint": ["GET"]` +- Wildcard paths: `"https://api.example.com/*": ["GET", "POST"]` +- Subdomain wildcards: `"https://*.example.com": ["GET"]` + +**Important**: Redirect destinations must also be included in `allowedUrls` if you want to follow redirects. + +#### WebSocket Permission Structure + +WebSocket permissions require explicit URL whitelisting: + +```json +{ + "websocket": { + "reason": "To connect to Discord gateway for real-time Rich Presence updates", + "allowedUrls": ["wss://gateway.discord.gg", "wss://*.discord.gg"], + "allowLocalNetwork": false + } +} +``` + +**Fields:** + +- `reason` (required): Explanation of why WebSocket access is needed +- `allowedUrls` (required): Array of WebSocket URL patterns (must start with `ws://` or `wss://`) +- `allowLocalNetwork` (optional, default false): Whether to allow connections to localhost/private IPs + +#### SubsonicAPI Permission Structure + +SubsonicAPI permissions control which users plugins can access Navidrome's Subsonic API as, providing fine-grained security controls: + +```json +{ + "subsonicapi": { + "reason": "To query music library data for recommendation engine", + "allowedUsernames": ["plugin-user", "readonly-user"], + "allowAdmins": false + } +} +``` + +**Fields:** + +- `reason` (required): Explanation of why SubsonicAPI access is needed +- `allowedUsernames` (optional): Array of specific usernames the plugin is allowed to use. If empty or omitted, any username can be used +- `allowAdmins` (optional, default false): Whether the plugin can make API calls using admin user accounts + +**Security Model:** + +The SubsonicAPI service enforces strict user-based access controls: + +- **Username Validation**: The plugin must provide a valid `u` (username) parameter in all API calls +- **User Context**: All API responses are filtered based on the specified user's permissions and library access +- **Admin Protection**: By default, plugins cannot use admin accounts for API calls to prevent privilege escalation +- **Username Restrictions**: When `allowedUsernames` is specified, only those users can be used + +**Common Permission Patterns:** + +```jsonc +// Allow any non-admin user (most permissive) +{ + "subsonicapi": { + "reason": "To search music library for metadata enhancement", + "allowAdmins": false + } +} + +// Allow only specific users (most secure) +{ + "subsonicapi": { + "reason": "To access playlists for synchronization with external service", + "allowedUsernames": ["sync-user"], + "allowAdmins": false + } +} + +// Allow admin users (use with caution) +{ + "subsonicapi": { + "reason": "To perform administrative tasks like library statistics", + "allowAdmins": true + } +} + +// Restrict to specific users but allow admins +{ + "subsonicapi": { + "reason": "To backup playlists for authorized users only", + "allowedUsernames": ["backup-admin", "user1", "user2"], + "allowAdmins": true + } +} +``` + +**Important Notes:** + +- Username matching is case-insensitive +- If `allowedUsernames` is empty or omitted, any username can be used (subject to `allowAdmins` setting) +- Admin restriction (`allowAdmins: false`) is checked after username validation +- Invalid or non-existent usernames will result in API call errors + +### Permission Validation + +The plugin system validates permissions during loading: + +1. **Schema Validation**: The manifest is validated against the JSON schema +2. **Permission Recognition**: Unknown permission keys are silently accepted for forward compatibility +3. **Service Loading**: Only services with corresponding permissions are made available to the plugin + +### Security Model + +The permission system provides multiple layers of security: + +#### 1. Principle of Least Privilege + +- Plugins start with zero permissions +- Only explicitly requested services are available +- No way to escalate privileges at runtime + +#### 2. Load-time Enforcement + +- Unauthorized services are not loaded into the WASM runtime +- No performance overhead for permission checks during execution +- Impossible to bypass restrictions through code manipulation + +#### 3. Service Isolation + +- Each plugin gets its own isolated service instances +- Plugins cannot interfere with each other's service usage +- Host services are sandboxed within the WASM environment + +### Best Practices for Plugin Developers + +#### Request Minimal Permissions + +```jsonc +// Good: No permissions if none needed +{ + "permissions": {} +} + +// Good: Only request what you need with clear reasoning +{ + "permissions": { + "http": { + "reason": "To fetch artist biography from MusicBrainz database", + "allowedUrls": { + "https://musicbrainz.org/ws/2/artist/*": ["GET"] + }, + "allowLocalNetwork": false + } + } +} + +// Avoid: Requesting unnecessary permissions +{ + "permissions": { + "http": { + "reason": "To fetch data", + "allowedUrls": { + "https://*": ["*"] + }, + "allowLocalNetwork": true + }, + "cache": { + "reason": "For caching" + }, + "scheduler": { + "reason": "For scheduling" + }, + "websocket": { + "reason": "For real-time updates", + "allowedUrls": ["wss://*"], + "allowLocalNetwork": true + } + } +} +``` + +#### Write Clear Permission Reasons + +Provide specific, descriptive reasons for each permission that explain exactly what the plugin does. Good reasons should: + +- Specify **what data** will be accessed/fetched +- Mention **which external services** will be contacted (if applicable) +- Explain **why** the permission is necessary for the plugin's functionality +- Use clear, non-technical language that users can understand + +```jsonc +// Good: Specific and informative +{ + "http": { + "reason": "To fetch album reviews from AllMusic API and artist biographies from MusicBrainz", + "allowedUrls": { + "https://www.allmusic.com/api/*": ["GET"], + "https://musicbrainz.org/ws/2/*": ["GET"] + }, + "allowLocalNetwork": false + }, + "cache": { + "reason": "To cache API responses for 24 hours to respect rate limits and improve performance" + } +} + +// Bad: Vague and unhelpful +{ + "http": { + "reason": "To make requests", + "allowedUrls": { + "https://*": ["*"] + }, + "allowLocalNetwork": true + }, + "cache": { + "reason": "For caching" + } +} +``` + +#### Handle Missing Permissions Gracefully + +Your plugin should provide clear error messages when permissions are missing: + +```go +func (p *Plugin) GetArtistInfo(ctx context.Context, req *api.ArtistInfoRequest) (*api.ArtistInfoResponse, error) { + // This will fail with "function not exported" if http permission is missing + resp, err := p.httpClient.Get(ctx, &http.HttpRequest{Url: apiURL}) + if err != nil { + // Check if it's a permission error + if strings.Contains(err.Error(), "not exported") { + return &api.ArtistInfoResponse{ + Error: "Plugin requires 'http' permission (reason: 'To fetch artist metadata from external APIs') - please add to manifest.json", + }, nil + } + return &api.ArtistInfoResponse{Error: err.Error()}, nil + } + // ... process response +} +``` + +### Troubleshooting Permissions + +#### Common Error Messages + +**"function not exported in module env"** + +- Cause: Plugin trying to call a service without proper permission +- Solution: Add the required permission to your manifest.json + +**"manifest validation failed" or "missing required field"** + +- Cause: Plugin manifest is missing required fields (e.g., `allowedUrls` for HTTP/WebSocket permissions) +- Solution: Ensure your manifest includes all required fields for each permission type + +**Permission silently ignored** + +- Cause: Using a permission key not recognized by current Navidrome version +- Effect: The unknown permission is silently ignored (no error or warning) +- Solution: This is actually normal behavior for forward compatibility + +#### Debugging Permission Issues + +1. **Check the manifest**: Ensure required permissions are spelled correctly and present +2. **Verify required fields**: Check that HTTP and WebSocket permissions include `allowedUrls` and other required fields +3. **Review logs**: Check for plugin loading errors, manifest validation errors, and WASM runtime errors +4. **Test incrementally**: Add permissions one at a time to identify which services your plugin needs +5. **Verify service names**: Ensure permission keys match exactly: `http`, `cache`, `config`, `scheduler`, `websocket`, `artwork`, `subsonicapi` +6. **Validate manifest**: Use a JSON schema validator to check your manifest against the schema + +### Future Considerations + +The permission system is designed for extensibility: + +- **Unknown permissions** are allowed in manifests for forward compatibility +- **New services** can be added with corresponding permission keys +- **Permission scoping** could be added in the future (e.g., read-only vs. read-write access) + +This ensures that plugins developed today will continue to work as the system evolves, while maintaining strong security boundaries. + +## Plugin System Implementation + +Navidrome's plugin system is built using the following key libraries: + +### 1. WebAssembly Runtime (Wazero) + +The plugin system uses [Wazero](https://github.com/tetratelabs/wazero), a WebAssembly runtime written in pure Go. Wazero was chosen for several reasons: + +- **No CGO dependency**: Unlike other WebAssembly runtimes, Wazero is implemented in pure Go, which simplifies cross-compilation and deployment. +- **Performance**: It provides efficient compilation and caching of WebAssembly modules. +- **Security**: Wazero enforces strict sandboxing, which is important for running third-party plugin code safely. + +The plugin manager uses Wazero to: + +- Compile and cache WebAssembly modules +- Create isolated runtime environments for each plugin +- Instantiate plugin modules when they're called +- Provide host functions that plugins can call + +### 2. Go-plugin Framework + +Navidrome builds on [go-plugin](https://github.com/knqyf263/go-plugin), a Go plugin system over WebAssembly that provides: + +- **Code generation**: Custom Protocol Buffer compiler plugin (`protoc-gen-go-plugin`) that generates Go code for both the host and WebAssembly plugins +- **Host function system**: Framework for exposing host functionality to plugins safely +- **Interface versioning**: Built-in mechanism for handling API compatibility between the host and plugins +- **Type conversion**: Utilities for marshaling and unmarshaling data between Go and WebAssembly + +This framework significantly simplifies plugin development by handling the low-level details of WebAssembly communication, allowing plugin developers to focus on implementing capabilities interfaces. + +### 3. Protocol Buffers (Protobuf) + +[Protocol Buffers](https://developers.google.com/protocol-buffers) serve as the interface definition language for the plugin system. Navidrome uses: + +- **protoc-gen-go-plugin**: A custom protobuf compiler plugin that generates Go code for both the Navidrome host and WebAssembly plugins +- Protobuf messages for structured data exchange between the host and plugins + +The protobuf definitions are located in: + +- `plugins/api/api.proto`: Core plugin capability interfaces +- `plugins/host/http/http.proto`: HTTP service interface +- `plugins/host/scheduler/scheduler.proto`: Scheduler service interface +- `plugins/host/config/config.proto`: Config service interface +- `plugins/host/websocket/websocket.proto`: WebSocket service interface +- `plugins/host/cache/cache.proto`: Cache service interface +- `plugins/host/artwork/artwork.proto`: Artwork service interface +- `plugins/host/subsonicapi/subsonicapi.proto`: SubsonicAPI service interface + +### 4. Integration Architecture + +The plugin system integrates these libraries through several key components: + +- **Plugin Manager**: Manages the lifecycle of plugins, from discovery to loading +- **Compilation Cache**: Improves performance by caching compiled WebAssembly modules +- **Host Function Bridge**: Exposes Navidrome functionality to plugins through WebAssembly imports +- **Capability Adapters**: Convert between the plugin API and Navidrome's internal interfaces + +Each plugin method call: + +1. Creates a new isolated plugin instance using Wazero +2. Executes the method in the sandboxed environment +3. Converts data between Go and WebAssembly formats using the protobuf-generated code +4. Cleans up the instance after the call completes + +This stateless design ensures that plugins remain isolated and can't interfere with Navidrome's core functionality or each other. + +## Configuration + +Plugins are configured in Navidrome's main configuration via the `Plugins` section: + +```toml +[Plugins] +# Enable or disable plugin support +Enabled = true + +# Directory where plugins are stored (defaults to [DataFolder]/plugins) +Folder = "/path/to/plugins" +``` + +By default, the plugins folder is created under `[DataFolder]/plugins` with restrictive permissions (`0700`) to limit access to the Navidrome user. + +### Plugin-specific Configuration + +You can also provide plugin-specific configuration using the `PluginConfig` section. Each plugin can have its own configuration map using the **folder name** as the key: + +```toml +[PluginConfig.my-plugin-folder] +api_key = "your-api-key" +user_id = "your-user-id" +enable_feature = "true" + +[PluginConfig.another-plugin-folder] +server_url = "https://example.com/api" +timeout = "30" +``` + +These configuration values are passed to plugins during initialization through the `OnInit` method in the `LifecycleManagement` capability. +Plugins that implement the `LifecycleManagement` capability will receive their configuration as a map of string keys and values. + +## Plugin Directory Structure + +Each plugin must be located in its own directory under the plugins folder: + +``` +plugins/ +├── my-plugin/ +│ ├── plugin.wasm # Compiled WebAssembly module +│ └── manifest.json # Plugin manifest defining metadata and capabilities +├── another-plugin/ +│ ├── plugin.wasm +│ └── manifest.json +``` + +**Note**: Plugin identification has changed! Navidrome now uses the **folder name** as the unique identifier for plugins, not the `name` field in `manifest.json`. This means: + +- **Multiple plugins can have the same `name` in their manifest**, as long as they are in different folders +- **Plugin loading and commands use the folder name**, not the manifest name +- **Folder names must be unique** across all plugins in your plugins directory + +This change allows you to have multiple versions or variants of the same plugin (e.g., `lastfm-official`, `lastfm-custom`, `lastfm-dev`) that all have the same manifest name but coexist peacefully. + +### Example: Multiple Plugin Variants + +``` +plugins/ +├── lastfm-official/ +│ ├── plugin.wasm +│ └── manifest.json # {"name": "LastFM Agent", ...} +├── lastfm-custom/ +│ ├── plugin.wasm +│ └── manifest.json # {"name": "LastFM Agent", ...} +└── lastfm-dev/ + ├── plugin.wasm + └── manifest.json # {"name": "LastFM Agent", ...} +``` + +All three plugins can have the same `"name": "LastFM Agent"` in their manifest, but they are identified and loaded by their folder names: + +```bash +# Load specific variants +navidrome plugin refresh lastfm-official +navidrome plugin refresh lastfm-custom +navidrome plugin refresh lastfm-dev + +# Configure each variant separately +[PluginConfig.lastfm-official] +api_key = "production-key" + +[PluginConfig.lastfm-dev] +api_key = "development-key" +``` + +### Using Symlinks for Plugin Variants + +Symlinks provide a powerful way to create multiple configurations for the same plugin without duplicating files. When you create a symlink to a plugin directory, Navidrome treats the symlink as a separate plugin with its own configuration. + +**Example: Discord Rich Presence with Multiple Configurations** + +```bash +# Create symlinks for different environments +cd /path/to/navidrome/plugins +ln -s /path/to/discord-rich-presence-plugin drp-prod +ln -s /path/to/discord-rich-presence-plugin drp-dev +ln -s /path/to/discord-rich-presence-plugin drp-test +``` + +Directory structure: + +``` +plugins/ +├── drp-prod -> /path/to/discord-rich-presence-plugin/ +├── drp-dev -> /path/to/discord-rich-presence-plugin/ +├── drp-test -> /path/to/discord-rich-presence-plugin/ +``` + +Each symlink can have its own configuration: + +```toml +[PluginConfig.drp-prod] +clientid = "production-client-id" +users = "admin:prod-token" + +[PluginConfig.drp-dev] +clientid = "development-client-id" +users = "admin:dev-token,testuser:test-token" + +[PluginConfig.drp-test] +clientid = "test-client-id" +users = "testuser:test-token" +``` + +**Key Benefits:** + +- **Single Source**: One plugin implementation serves multiple use cases +- **Independent Configuration**: Each symlink has its own configuration namespace +- **Development Workflow**: Easy to test different configurations without code changes +- **Resource Sharing**: All symlinks share the same compiled WASM binary + +**Important Notes:** + +- The **symlink name** (not the target folder name) is used as the plugin ID +- Configuration keys use the symlink name: `PluginConfig.` +- Each symlink appears as a separate plugin in `navidrome plugin list` +- CLI commands use the symlink name: `navidrome plugin refresh drp-dev` + +## Plugin Package Format (.ndp) + +Navidrome Plugin Packages (.ndp) are ZIP archives that bundle all files needed for a plugin. They can be installed using the `navidrome plugin install` command. + +### Package Structure + +A valid .ndp file must contain: + +``` +plugin-name.ndp (ZIP file) +├── plugin.wasm # Required: The compiled WebAssembly module +├── manifest.json # Required: Plugin manifest with metadata +├── README.md # Optional: Documentation +└── LICENSE # Optional: License information +``` + +### Creating a Plugin Package + +To create a plugin package: + +1. Compile your plugin to WebAssembly (plugin.wasm) +2. Create a manifest.json file with required fields +3. Include any documentation files you want to bundle +4. Create a ZIP archive of all files +5. Rename the ZIP file to have a .ndp extension + +### Installing a Plugin Package + +Use the Navidrome CLI to install plugins: + +```bash +navidrome plugin install /path/to/plugin-name.ndp +``` + +This will extract the plugin to a directory in your configured plugins folder. + +## Plugin Management + +Navidrome provides a command-line interface for managing plugins. To use these commands, the plugin system must be enabled in your configuration. + +### Available Commands + +```bash +# List all installed plugins +navidrome plugin list + +# Show detailed information about a plugin package or installed plugin +navidrome plugin info plugin-name-or-package.ndp + +# Install a plugin from a .ndp file +navidrome plugin install /path/to/plugin.ndp + +# Remove an installed plugin (use folder name) +navidrome plugin remove plugin-folder-name + +# Update an existing plugin +navidrome plugin update /path/to/updated-plugin.ndp + +# Reload a plugin without restarting Navidrome (use folder name) +navidrome plugin refresh plugin-folder-name + +# Create a symlink to a plugin development folder +navidrome plugin dev /path/to/dev/folder +``` + +### Plugin Development + +The `dev` and `refresh` commands are particularly useful for plugin development: + +#### Development Workflow + +1. Create a plugin development folder with required files (`manifest.json` and `plugin.wasm`) +2. Run `navidrome plugin dev /path/to/your/plugin` to create a symlink in the plugins directory +3. Make changes to your plugin code +4. Recompile the WebAssembly module +5. Run `navidrome plugin refresh your-plugin-folder-name` to reload the plugin without restarting Navidrome + +The `dev` command creates a symlink from your development folder to the plugins directory, allowing you to edit the plugin files directly in your development environment without copying them to the plugins directory after each change. + +The refresh process: + +- Reloads the plugin manifest +- Recompiles the WebAssembly module +- Updates the plugin registration +- Makes the updated plugin immediately available to Navidrome + +### Plugin Security + +Navidrome provides multiple layers of security for plugin execution: + +1. **WebAssembly Sandbox**: Plugins run in isolated WebAssembly environments with no direct system access +2. **Permission System**: Plugins can only access host services they explicitly request in their manifest (see [Plugin Permission System](#plugin-permission-system)) +3. **File System Security**: The plugins folder is configured with restricted permissions (0700) accessible only by the user running Navidrome +4. **Resource Isolation**: Each plugin instance is isolated and cannot interfere with other plugins or core Navidrome functionality + +The permission system ensures that plugins follow the principle of least privilege - they start with no access to host services and must explicitly declare what they need. This prevents malicious or poorly written plugins from accessing unauthorized functionality. + +Always ensure you trust the source of any plugins you install, and review their requested permissions before installation. + +## Plugin Manifest + +**Capability Names Are Case-Sensitive**: Entries in the `capabilities` array must exactly match one of the supported capabilities: `MetadataAgent`, `Scrobbler`, `SchedulerCallback`, `WebSocketCallback`, or `LifecycleManagement`. +**Manifest Validation**: The `manifest.json` is validated against the embedded JSON schema (`plugins/schema/manifest.schema.json`). Invalid manifests will be rejected during plugin discovery. + +Every plugin must provide a `manifest.json` file that declares metadata, capabilities, and permissions: + +```json +{ + "name": "my-awesome-plugin", + "author": "Your Name", + "version": "1.0.0", + "description": "A plugin that does awesome things", + "website": "https://github.com/yourname/my-awesome-plugin", + "capabilities": [ + "MetadataAgent", + "Scrobbler", + "SchedulerCallback", + "WebSocketCallback", + "LifecycleManagement" + ], + "permissions": { + "http": { + "reason": "To fetch metadata from external music APIs" + }, + "cache": { + "reason": "To cache API responses and reduce rate limiting" + }, + "config": { + "reason": "To read API keys and service configuration" + }, + "scheduler": { + "reason": "To schedule periodic data refresh tasks" + } + } +} +``` + +Required fields: + +- `name`: Display name of the plugin (used for documentation/display purposes; folder name is used for identification) +- `author`: The creator or organization behind the plugin +- `version`: Version identifier (recommended to follow semantic versioning) +- `description`: A brief description of what the plugin does +- `website`: Website URL for the plugin documentation, source code, or homepage (must be a valid URI) +- `capabilities`: Array of capability types the plugin implements +- `permissions`: Object mapping host service names to their configurations (use empty object `{}` for no permissions) + +Currently supported capabilities: + +- `MetadataAgent` - For implementing media metadata providers +- `Scrobbler` - For implementing scrobbling plugins +- `SchedulerCallback` - For implementing timed callbacks +- `WebSocketCallback` - For interacting with WebSocket endpoints and handling WebSocket events +- `LifecycleManagement` - For handling plugin initialization and configuration + +## Plugin Loading Process + +1. The Plugin Manager scans the plugins directory and all subdirectories +2. For each subdirectory containing a `plugin.wasm` file and valid `manifest.json`, the manager: + - Validates the manifest and checks for supported capabilities + - Pre-compiles the WASM module in the background + - Registers the plugin using the **folder name** as the unique identifier in the plugin registry +3. Plugins can be loaded on-demand by folder name or all at once, depending on the manager's method calls + +## Writing a Plugin + +### Requirements + +1. Your plugin must be compiled to WebAssembly (WASM) +2. Your plugin must implement at least one of the capability interfaces defined in `api.proto` +3. Your plugin must be placed in its own directory with a proper `manifest.json` + +### Plugin Registration Functions + +The plugin API provides several registration functions that plugins can call during initialization to register capabilities and obtain host services. These functions should typically be called in your plugin's `init()` function. + +#### Standard Registration Functions + +```go +func RegisterMetadataAgent(agent MetadataAgent) +func RegisterScrobbler(scrobbler Scrobbler) +func RegisterSchedulerCallback(callback SchedulerCallback) +func RegisterLifecycleManagement(lifecycle LifecycleManagement) +func RegisterWebSocketCallback(callback WebSocketCallback) +``` + +These functions register plugins for the standard capability interfaces: + +- **RegisterMetadataAgent**: Register a plugin that provides artist/album metadata and images +- **RegisterScrobbler**: Register a plugin that handles scrobbling to external services +- **RegisterSchedulerCallback**: Register a plugin that handles scheduled callbacks (single callback per plugin) +- **RegisterLifecycleManagement**: Register a plugin that handles initialization and configuration +- **RegisterWebSocketCallback**: Register a plugin that handles WebSocket events + +**Basic Usage Example:** + +```go +type MyPlugin struct { + // plugin implementation +} + +func init() { + plugin := &MyPlugin{} + + // Register capabilities your plugin implements + api.RegisterScrobbler(plugin) + api.RegisterLifecycleManagement(plugin) +} +``` + +#### RegisterNamedSchedulerCallback + +```go +func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService +``` + +This function registers a named scheduler callback and returns a scheduler service instance. Named callbacks allow a single plugin to register multiple scheduler callbacks for different purposes, each with its own identifier. + +**Parameters:** + +- `name` (string): A unique identifier for this scheduler callback within the plugin. This name is used to route scheduled events to the correct callback handler. +- `cb` (SchedulerCallback): An object that implements the `SchedulerCallback` interface + +**Returns:** + +- `scheduler.SchedulerService`: A scheduler service instance that can be used to schedule one-time or recurring tasks for this specific callback + +**Usage Example** (from Discord Rich Presence plugin): + +```go +func init() { + // Register multiple named scheduler callbacks for different purposes + plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin) + plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc) +} + +// The plugin implements SchedulerCallback to handle "close-activity" events +func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + log.Printf("Removing presence for user %s", req.ScheduleId) + // Handle close-activity scheduling events + return nil, d.rpc.clearActivity(ctx, req.ScheduleId) +} + +// The rpc component implements SchedulerCallback to handle "heartbeat" events +func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + // Handle heartbeat scheduling events + return nil, r.sendHeartbeat(ctx, req.ScheduleId) +} + +// Use the returned scheduler service to schedule tasks +func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) { + // Schedule a one-time callback to clear activity when track ends + _, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{ + ScheduleId: request.Username, + DelaySeconds: request.Track.Length - request.Track.Position + 5, + }) + return nil, err +} + +func (r *discordRPC) connect(ctx context.Context, username string, token string) error { + // Schedule recurring heartbeats for Discord connection + _, err := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + CronExpression: "@every 41s", + ScheduleId: username, + }) + return err +} +``` + +**Key Benefits:** + +- **Multiple Schedulers**: A single plugin can have multiple named scheduler callbacks for different purposes (e.g., "heartbeat", "cleanup", "refresh") +- **Isolated Scheduling**: Each named callback gets its own scheduler service, allowing independent scheduling management +- **Clear Separation**: Different callback handlers can be implemented on different objects within your plugin +- **Flexible Routing**: The scheduler automatically routes callbacks to the correct handler based on the registration name + +**Important Notes:** + +- The `name` parameter must be unique within your plugin, but can be the same across different plugins +- The returned scheduler service is specifically tied to the named callback you registered +- Scheduled events will call the `OnSchedulerCallback` method on the object you provided during registration +- You must implement the `SchedulerCallback` interface on the object you register + +#### RegisterSchedulerCallback vs RegisterNamedSchedulerCallback + +- **Use `RegisterSchedulerCallback`** when your plugin only needs a single scheduler callback +- **Use `RegisterNamedSchedulerCallback`** when your plugin needs multiple scheduler callbacks for different purposes (like the Discord plugin's "heartbeat" and "close-activity" callbacks) + +The named version allows better organization and separation of concerns when you have complex scheduling requirements. + +### Capability Interfaces + +#### Metadata Agent + +A capability fetches metadata about artists and albums. Implement this interface to add support for fetching data from external sources. + +```protobuf +service MetadataAgent { + // Artist metadata methods + rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse); + rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse); + rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse); + rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse); + rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse); + rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse); + + // Album metadata methods + rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse); + rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse); +} +``` + +#### Scrobbler + +This capability enables scrobbling to external services. Implement this interface to add support for custom scrobblers. + +```protobuf +service Scrobbler { + rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse); + rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse); + rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse); +} +``` + +#### Scheduler Callback + +This capability allows plugins to receive one-time or recurring scheduled callbacks. Implement this interface to add +support for scheduled tasks. See the [SchedulerService](#scheduler-service) for more information. + +```protobuf +service SchedulerCallback { + rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse); +} +``` + +#### WebSocket Callback + +This capability allows plugins to interact with WebSocket endpoints and handle WebSocket events. Implement this interface to add support for WebSocket-based communication. + +```protobuf +service WebSocketCallback { + // Called when a text message is received + rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse); + + // Called when a binary message is received + rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse); + + // Called when an error occurs + rpc OnError(OnErrorRequest) returns (OnErrorResponse); + + // Called when the connection is closed + rpc OnClose(OnCloseRequest) returns (OnCloseResponse); +} +``` + +Plugins can use the WebSocket host service to connect to WebSocket endpoints, send messages, and handle responses: + +```go +// Define a connection ID first +connectionID := "my-connection-id" + +// Connect to a WebSocket server +connectResp, err := websocket.Connect(ctx, &websocket.ConnectRequest{ + Url: "wss://example.com/ws", + Headers: map[string]string{"Authorization": "Bearer token"}, + ConnectionId: connectionID, +}) +if err != nil { + return err +} + +// Send a text message +_, err = websocket.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: connectionID, + Message: "Hello WebSocket", +}) + +// Close the connection when done +_, err = websocket.Close(ctx, &websocket.CloseRequest{ + ConnectionId: connectionID, + Code: 1000, // Normal closure + Reason: "Done", +}) +``` + +## Host Services + +Navidrome provides several host services that plugins can use to interact with external systems and access functionality. Plugins must declare permissions for each service they want to use in their `manifest.json`. + +### HTTP Service + +The HTTP service allows plugins to make HTTP requests to external APIs and services. To use this service, declare the `http` permission in your manifest. + +#### Basic Usage + +```json +{ + "permissions": { + "http": { + "reason": "To fetch artist metadata from external music APIs" + } + } +} +``` + +#### Granular Permissions + +For enhanced security, you can specify granular HTTP permissions that restrict which URLs and HTTP methods your plugin can access: + +```json +{ + "permissions": { + "http": { + "reason": "To fetch album reviews from AllMusic and artist data from MusicBrainz", + "allowedUrls": { + "https://api.allmusic.com": ["GET", "POST"], + "https://*.musicbrainz.org": ["GET"], + "https://coverartarchive.org": ["GET"], + "*": ["GET"] + }, + "allowLocalNetwork": false + } + } +} +``` + +**Permission Fields:** + +- `reason` (required): Clear explanation of why HTTP access is needed +- `allowedUrls` (required): Map of URL patterns to allowed HTTP methods + + - Must contain at least one URL pattern + - For unrestricted access, use: `{"*": ["*"]}` + - Keys can be exact URLs, wildcard patterns, or `*` for any URL + - Values are arrays of HTTP methods: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`, `OPTIONS`, or `*` for any method + - **Important**: Redirect destinations must also be included in this list. If a URL redirects to another URL not in `allowedUrls`, the redirect will be blocked. + +- `allowLocalNetwork` (optional, default: `false`): Whether to allow requests to localhost/private IPs + +**URL Pattern Matching:** + +- Exact URLs: `https://api.example.com` +- Wildcard subdomains: `https://*.example.com` (matches any subdomain) +- Wildcard paths: `https://example.com/api/*` (matches any path under /api/) +- Global wildcard: `*` (matches any URL - use with caution) + +**Examples:** + +```json +// Allow only GET requests to specific APIs +{ + "allowedUrls": { + "https://api.last.fm": ["GET"], + "https://ws.audioscrobbler.com": ["GET"] + } +} + +// Allow any method to a trusted domain, GET everywhere else +{ + "allowedUrls": { + "https://my-trusted-api.com": ["*"], + "*": ["GET"] + } +} + +// Handle redirects by including redirect destinations +{ + "allowedUrls": { + "https://short.ly/api123": ["GET"], // Original URL + "https://api.actual-service.com": ["GET"] // Redirect destination + } +} + +// Strict permissions for a secure plugin (blocks redirects by not including redirect destinations) +{ + "allowedUrls": { + "https://api.musicbrainz.org/ws/2": ["GET"] + }, + "allowLocalNetwork": false +} +``` + +#### Security Considerations + +The HTTP service implements several security features: + +1. **Local Network Protection**: By default, requests to localhost and private IP ranges are blocked +2. **URL Filtering**: Only URLs matching `allowedUrls` patterns are allowed +3. **Method Restrictions**: HTTP methods are validated against the allowed list for each URL pattern +4. **Redirect Security**: + - Redirect destinations must also match `allowedUrls` patterns and methods + - Maximum of 5 redirects per request to prevent redirect loops + - To block all redirects, simply don't include any redirect destinations in `allowedUrls` + +**Private IP Ranges Blocked (when `allowLocalNetwork: false`):** + +- IPv4: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `127.0.0.0/8`, `169.254.0.0/16` +- IPv6: `::1`, `fe80::/10`, `fc00::/7` +- Hostnames: `localhost` + +#### Making HTTP Requests + +```go +import "github.com/navidrome/navidrome/plugins/host/http" + +// GET request +resp, err := httpClient.Get(ctx, &http.HttpRequest{ + Url: "https://api.example.com/data", + Headers: map[string]string{ + "Authorization": "Bearer " + token, + "User-Agent": "MyPlugin/1.0", + }, + TimeoutMs: 5000, +}) + +// POST request with body +resp, err := httpClient.Post(ctx, &http.HttpRequest{ + Url: "https://api.example.com/submit", + Headers: map[string]string{ + "Content-Type": "application/json", + }, + Body: []byte(`{"key": "value"}`), + TimeoutMs: 10000, +}) + +// Handle response +if err != nil { + return &api.Response{Error: "HTTP request failed: " + err.Error()}, nil +} + +if resp.Error != "" { + return &api.Response{Error: "HTTP error: " + resp.Error}, nil +} + +if resp.Status != 200 { + return &api.Response{Error: fmt.Sprintf("HTTP %d: %s", resp.Status, string(resp.Body))}, nil +} + +// Use response data +data := resp.Body +headers := resp.Headers +``` + +### Other Host Services + +#### Config Service + +Access plugin-specific configuration: + +```json +{ + "permissions": { + "config": { + "reason": "To read API keys and service endpoints from plugin configuration" + } + } +} +``` + +#### Cache Service + +Store and retrieve data to improve performance: + +```json +{ + "permissions": { + "cache": { + "reason": "To cache API responses and reduce external service calls" + } + } +} +``` + +#### Scheduler Service + +Schedule recurring or one-time tasks: + +```json +{ + "permissions": { + "scheduler": { + "reason": "To schedule periodic metadata refresh and cleanup tasks" + } + } +} +``` + +#### WebSocket Service + +Connect to WebSocket endpoints: + +```json +{ + "permissions": { + "websocket": { + "reason": "To connect to real-time music service APIs for live data", + "allowedUrls": [ + "wss://api.musicservice.com/ws", + "wss://realtime.example.com" + ], + "allowLocalNetwork": false + } + } +} +``` + +#### Artwork Service + +Generate public URLs for artwork: + +```json +{ + "permissions": { + "artwork": { + "reason": "To generate public URLs for album and artist images" + } + } +} +``` + +### Error Handling + +Plugins should use the standard error values (`plugin:not_found`, `plugin:not_implemented`) to indicate resource-not-found and unimplemented-method scenarios. All other errors will be propagated directly to the caller. Ensure your capability methods return errors via the response message `error` fields rather than panicking or relying on transport errors. + +## Plugin Lifecycle and Statelessness + +**Important**: Navidrome plugins are stateless. Each method call creates a new plugin instance which is destroyed afterward. This has several important implications: + +1. **No in-memory persistence**: Plugins cannot store state between method calls in memory +2. **Each call is isolated**: Variables, configurations, and runtime state don't persist between calls +3. **No shared resources**: Each plugin instance has its own memory space + +This stateless design is crucial for security and stability: + +- Memory leaks in one call won't affect subsequent operations +- A crashed plugin instance won't bring down the entire system +- Resource usage is more predictable and contained + +When developing plugins, keep these guidelines in mind: + +- Don't try to cache data in memory between calls +- Don't store authentication tokens or session data in variables +- If persistence is needed, use external storage or the host's HTTP interface +- Performance optimizations should focus on efficient per-call execution + +### Using Plugin Configuration + +Since plugins are stateless, you can use the `LifecycleManagement` interface to read configuration when your plugin is loaded and perform any necessary setup: + +```go +func (p *myPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + // Access plugin configuration + apiKey := req.Config["api_key"] + if apiKey == "" { + return &api.InitResponse{Error: "Missing API key in configuration"}, nil + } + + // Validate configuration + serverURL := req.Config["server_url"] + if serverURL == "" { + serverURL = "https://default-api.example.com" // Use default if not specified + } + + // Perform initialization tasks (e.g., validate API key) + httpClient := &http.HttpServiceClient{} + resp, err := httpClient.Get(ctx, &http.HttpRequest{ + Url: serverURL + "/validate?key=" + apiKey, + }) + if err != nil { + return &api.InitResponse{Error: "Failed to validate API key: " + err.Error()}, nil + } + + if resp.StatusCode != 200 { + return &api.InitResponse{Error: "Invalid API key"}, nil + } + + return &api.InitResponse{}, nil +} +``` + +Remember, the `OnInit` method is called only once when the plugin is loaded. It cannot store any state that needs to persist between method calls. It's primarily useful for: + +1. Validating required configuration +2. Checking API credentials +3. Verifying connectivity to external services +4. Initializing any external resources + +## Caching + +The plugin system implements a compilation cache to improve performance: + +1. Compiled WASM modules are cached in `[CacheFolder]/plugins` +2. This reduces startup time for plugins that have already been compiled +3. The cache has a automatic cleanup mechanism to remove old modules. + - when the cache folder exceeds `Plugins.CacheSize` (default 100MB), + the oldest modules are removed + +### WASM Loading Optimization + +To improve performance during plugin instance creation, the system implements an optimization that avoids repeated file reads and compilation: + +1. **Precompilation**: During plugin discovery, WASM files are read and compiled in the background, with both the MD5 hash of the file bytes and compiled modules cached in memory. + +2. **Optimized Runtime**: After precompilation completes, plugins use an `optimizedRuntime` wrapper that overrides `CompileModule` to detect when the same WASM bytes are being compiled by comparing MD5 hashes. + +3. **Cache Hit**: When the generated plugin code calls `os.ReadFile()` and `CompileModule()`, the optimization calculates the MD5 hash of the incoming bytes and compares it with the cached hash. If they match, it returns the pre-compiled module directly. + +4. **Performance Benefit**: This eliminates repeated compilation while using minimal memory (16 bytes per plugin for the MD5 hash vs potentially MB of WASM bytes), significantly improving plugin instance creation speed while maintaining full compatibility with the generated API code. + +5. **Memory Efficiency**: By storing only MD5 hashes instead of full WASM bytes, the optimization scales efficiently regardless of plugin size or count. + +The optimization is transparent to plugin developers and automatically activates when plugins are successfully precompiled. + +## Best Practices + +1. **Resource Management**: + + - The host handles HTTP response cleanup, so no need to close response objects + - Keep plugin instances lightweight as they are created and destroyed frequently + +2. **Error Handling**: + + - Use the standard error types when appropriate + - Return descriptive error messages for debugging + - Custom errors are supported and will be propagated to the caller + +3. **Performance**: + + - Remember plugins are stateless, so don't rely on local variables for caching. Use the CacheService for caching data. + - Use efficient algorithms that work well in single-call scenarios + +4. **Security**: + - Only request permissions you actually need (see [Plugin Permission System](#plugin-permission-system)) + - Validate inputs to prevent injection attacks + - Don't store sensitive credentials in the plugin code + - Use configuration for API keys and sensitive data + +## Limitations + +1. WASM plugins have limited access to system resources +2. Plugin compilation has an initial overhead on first load, as it needs to be compiled to WebAssembly + - Subsequent calls are faster due to caching +3. New plugin capabilities types require changes to the core codebase +4. Stateless nature prevents certain optimizations + +## Troubleshooting + +1. **Plugin not detected**: + + - Ensure `plugin.wasm` and `manifest.json` exist in the plugin directory + - Check that the manifest contains valid capabilities names + - Verify the manifest schema is valid (see [Plugin Permission System](#plugin-permission-system)) + +2. **Permission errors**: + + - **"function not exported in module env"**: Plugin trying to use a service without proper permission + - Check that required permissions are declared in `manifest.json` + - See [Troubleshooting Permissions](#troubleshooting-permissions) for detailed guidance + +3. **Compilation errors**: + + - Check logs for WASM compilation errors + - Verify the plugin is compatible with the current API version + +4. **Runtime errors**: + - Look for error messages in the Navidrome logs + - Add debug logging to your plugin + - Check if the error is permission-related before debugging plugin logic diff --git a/plugins/adapter_media_agent.go b/plugins/adapter_media_agent.go new file mode 100644 index 0000000..eca8912 --- /dev/null +++ b/plugins/adapter_media_agent.go @@ -0,0 +1,166 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/tetratelabs/wazero" +) + +// NewWasmMediaAgent creates a new adapter for a MetadataAgent plugin +func newWasmMediaAgent(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { + loader, err := api.NewMetadataAgentPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) + if err != nil { + log.Error("Error creating media metadata service plugin", "plugin", pluginID, "path", wasmPath, err) + return nil + } + return &wasmMediaAgent{ + baseCapability: newBaseCapability[api.MetadataAgent, *api.MetadataAgentPlugin]( + wasmPath, + pluginID, + CapabilityMetadataAgent, + m.metrics, + loader, + func(ctx context.Context, l *api.MetadataAgentPlugin, path string) (api.MetadataAgent, error) { + return l.Load(ctx, path) + }, + ), + } +} + +// wasmMediaAgent adapts a MetadataAgent plugin to implement the agents.Interface +type wasmMediaAgent struct { + *baseCapability[api.MetadataAgent, *api.MetadataAgentPlugin] +} + +func (w *wasmMediaAgent) AgentName() string { + return w.id +} + +func (w *wasmMediaAgent) mapError(err error) error { + if err != nil && (err.Error() == api.ErrNotFound.Error() || err.Error() == api.ErrNotImplemented.Error()) { + return agents.ErrNotFound + } + return err +} + +// Album-related methods + +func (w *wasmMediaAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) { + res, err := callMethod(ctx, w, "GetAlbumInfo", func(inst api.MetadataAgent) (*api.AlbumInfoResponse, error) { + return inst.GetAlbumInfo(ctx, &api.AlbumInfoRequest{Name: name, Artist: artist, Mbid: mbid}) + }) + if err != nil { + return nil, w.mapError(err) + } + if res == nil || res.Info == nil { + return nil, agents.ErrNotFound + } + info := res.Info + return &agents.AlbumInfo{ + Name: info.Name, + MBID: info.Mbid, + Description: info.Description, + URL: info.Url, + }, nil +} + +func (w *wasmMediaAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) { + res, err := callMethod(ctx, w, "GetAlbumImages", func(inst api.MetadataAgent) (*api.AlbumImagesResponse, error) { + return inst.GetAlbumImages(ctx, &api.AlbumImagesRequest{Name: name, Artist: artist, Mbid: mbid}) + }) + if err != nil { + return nil, w.mapError(err) + } + return convertExternalImages(res.Images), nil +} + +// Artist-related methods + +func (w *wasmMediaAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) { + res, err := callMethod(ctx, w, "GetArtistMBID", func(inst api.MetadataAgent) (*api.ArtistMBIDResponse, error) { + return inst.GetArtistMBID(ctx, &api.ArtistMBIDRequest{Id: id, Name: name}) + }) + if err != nil { + return "", w.mapError(err) + } + return res.GetMbid(), nil +} + +func (w *wasmMediaAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) { + res, err := callMethod(ctx, w, "GetArtistURL", func(inst api.MetadataAgent) (*api.ArtistURLResponse, error) { + return inst.GetArtistURL(ctx, &api.ArtistURLRequest{Id: id, Name: name, Mbid: mbid}) + }) + if err != nil { + return "", w.mapError(err) + } + return res.GetUrl(), nil +} + +func (w *wasmMediaAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) { + res, err := callMethod(ctx, w, "GetArtistBiography", func(inst api.MetadataAgent) (*api.ArtistBiographyResponse, error) { + return inst.GetArtistBiography(ctx, &api.ArtistBiographyRequest{Id: id, Name: name, Mbid: mbid}) + }) + if err != nil { + return "", w.mapError(err) + } + return res.GetBiography(), nil +} + +func (w *wasmMediaAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) { + resp, err := callMethod(ctx, w, "GetSimilarArtists", func(inst api.MetadataAgent) (*api.ArtistSimilarResponse, error) { + return inst.GetSimilarArtists(ctx, &api.ArtistSimilarRequest{Id: id, Name: name, Mbid: mbid, Limit: int32(limit)}) + }) + if err != nil { + return nil, w.mapError(err) + } + artists := make([]agents.Artist, 0, len(resp.GetArtists())) + for _, a := range resp.GetArtists() { + artists = append(artists, agents.Artist{ + Name: a.GetName(), + MBID: a.GetMbid(), + }) + } + return artists, nil +} + +func (w *wasmMediaAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) { + resp, err := callMethod(ctx, w, "GetArtistImages", func(inst api.MetadataAgent) (*api.ArtistImageResponse, error) { + return inst.GetArtistImages(ctx, &api.ArtistImageRequest{Id: id, Name: name, Mbid: mbid}) + }) + if err != nil { + return nil, w.mapError(err) + } + return convertExternalImages(resp.Images), nil +} + +func (w *wasmMediaAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) { + resp, err := callMethod(ctx, w, "GetArtistTopSongs", func(inst api.MetadataAgent) (*api.ArtistTopSongsResponse, error) { + return inst.GetArtistTopSongs(ctx, &api.ArtistTopSongsRequest{Id: id, ArtistName: artistName, Mbid: mbid, Count: int32(count)}) + }) + if err != nil { + return nil, w.mapError(err) + } + songs := make([]agents.Song, 0, len(resp.GetSongs())) + for _, s := range resp.GetSongs() { + songs = append(songs, agents.Song{ + Name: s.GetName(), + MBID: s.GetMbid(), + }) + } + return songs, nil +} + +// Helper function to convert ExternalImage objects from the API to the agents package +func convertExternalImages(images []*api.ExternalImage) []agents.ExternalImage { + result := make([]agents.ExternalImage, 0, len(images)) + for _, img := range images { + result = append(result, agents.ExternalImage{ + URL: img.GetUrl(), + Size: int(img.GetSize()), + }) + } + return result +} diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go new file mode 100644 index 0000000..e04baf8 --- /dev/null +++ b/plugins/adapter_media_agent_test.go @@ -0,0 +1,229 @@ +package plugins + +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/metrics" + "github.com/navidrome/navidrome/plugins/api" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Adapter Media Agent", func() { + var ctx context.Context + var mgr *managerImpl + + BeforeEach(func() { + ctx = GinkgoT().Context() + + // Ensure plugins folder is set to testdata + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Folder = testDataDir + conf.Server.DevPluginCompilationTimeout = 2 * time.Minute + + mgr = createManager(nil, metrics.NewNoopInstance()) + mgr.ScanPlugins() + + // Wait for all plugins to compile to avoid race conditions + err := mgr.EnsureCompiled("multi_plugin") + Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully") + err = mgr.EnsureCompiled("fake_album_agent") + Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully") + }) + + Describe("AgentName and PluginName", func() { + It("should return the plugin name", func() { + agent := mgr.LoadPlugin("multi_plugin", "MetadataAgent") + Expect(agent).NotTo(BeNil(), "multi_plugin should be loaded") + Expect(agent.PluginID()).To(Equal("multi_plugin")) + }) + It("should return the agent name", func() { + agent, ok := mgr.LoadMediaAgent("multi_plugin") + Expect(ok).To(BeTrue(), "multi_plugin should be loaded as media agent") + Expect(agent.AgentName()).To(Equal("multi_plugin")) + }) + }) + + Describe("Album methods", func() { + var agent *wasmMediaAgent + + BeforeEach(func() { + a, ok := mgr.LoadMediaAgent("fake_album_agent") + Expect(ok).To(BeTrue(), "fake_album_agent should be loaded") + agent = a.(*wasmMediaAgent) + }) + + Context("GetAlbumInfo", func() { + It("should return album information", func() { + info, err := agent.GetAlbumInfo(ctx, "Test Album", "Test Artist", "mbid") + + Expect(err).NotTo(HaveOccurred()) + Expect(info).NotTo(BeNil()) + Expect(info.Name).To(Equal("Test Album")) + Expect(info.MBID).To(Equal("album-mbid-123")) + Expect(info.Description).To(Equal("This is a test album description")) + Expect(info.URL).To(Equal("https://example.com/album")) + }) + + It("should return ErrNotFound when plugin returns not found", func() { + _, err := agent.GetAlbumInfo(ctx, "Test Album", "", "mbid") + + Expect(err).To(Equal(agents.ErrNotFound)) + }) + + It("should return ErrNotFound when plugin returns nil response", func() { + _, err := agent.GetAlbumInfo(ctx, "", "", "") + + Expect(err).To(Equal(agents.ErrNotFound)) + }) + }) + + Context("GetAlbumImages", func() { + It("should return album images", func() { + images, err := agent.GetAlbumImages(ctx, "Test Album", "Test Artist", "mbid") + + Expect(err).NotTo(HaveOccurred()) + Expect(images).To(Equal([]agents.ExternalImage{ + {URL: "https://example.com/album1.jpg", Size: 300}, + {URL: "https://example.com/album2.jpg", Size: 400}, + })) + }) + }) + }) + + Describe("Artist methods", func() { + var agent *wasmMediaAgent + + BeforeEach(func() { + a, ok := mgr.LoadMediaAgent("fake_artist_agent") + Expect(ok).To(BeTrue(), "fake_artist_agent should be loaded") + agent = a.(*wasmMediaAgent) + }) + + Context("GetArtistMBID", func() { + It("should return artist MBID", func() { + mbid, err := agent.GetArtistMBID(ctx, "artist-id", "Test Artist") + + Expect(err).NotTo(HaveOccurred()) + Expect(mbid).To(Equal("1234567890")) + }) + + It("should return ErrNotFound when plugin returns not found", func() { + _, err := agent.GetArtistMBID(ctx, "artist-id", "") + + Expect(err).To(Equal(agents.ErrNotFound)) + }) + }) + + Context("GetArtistURL", func() { + It("should return artist URL", func() { + url, err := agent.GetArtistURL(ctx, "artist-id", "Test Artist", "mbid") + + Expect(err).NotTo(HaveOccurred()) + Expect(url).To(Equal("https://example.com")) + }) + }) + + Context("GetArtistBiography", func() { + It("should return artist biography", func() { + bio, err := agent.GetArtistBiography(ctx, "artist-id", "Test Artist", "mbid") + + Expect(err).NotTo(HaveOccurred()) + Expect(bio).To(Equal("This is a test biography")) + }) + }) + + Context("GetSimilarArtists", func() { + It("should return similar artists", func() { + artists, err := agent.GetSimilarArtists(ctx, "artist-id", "Test Artist", "mbid", 10) + + Expect(err).NotTo(HaveOccurred()) + Expect(artists).To(Equal([]agents.Artist{ + {Name: "Similar Artist 1", MBID: "mbid1"}, + {Name: "Similar Artist 2", MBID: "mbid2"}, + })) + }) + }) + + Context("GetArtistImages", func() { + It("should return artist images", func() { + images, err := agent.GetArtistImages(ctx, "artist-id", "Test Artist", "mbid") + + Expect(err).NotTo(HaveOccurred()) + Expect(images).To(Equal([]agents.ExternalImage{ + {URL: "https://example.com/image1.jpg", Size: 100}, + {URL: "https://example.com/image2.jpg", Size: 200}, + })) + }) + }) + + Context("GetArtistTopSongs", func() { + It("should return artist top songs", func() { + songs, err := agent.GetArtistTopSongs(ctx, "artist-id", "Test Artist", "mbid", 10) + + Expect(err).NotTo(HaveOccurred()) + Expect(songs).To(Equal([]agents.Song{ + {Name: "Song 1", MBID: "mbid1"}, + {Name: "Song 2", MBID: "mbid2"}, + })) + }) + }) + }) + + Describe("Helper functions", func() { + It("convertExternalImages should convert API image objects to agent image objects", func() { + apiImages := []*api.ExternalImage{ + {Url: "https://example.com/image1.jpg", Size: 100}, + {Url: "https://example.com/image2.jpg", Size: 200}, + } + + agentImages := convertExternalImages(apiImages) + Expect(agentImages).To(HaveLen(2)) + + for i, img := range agentImages { + Expect(img.URL).To(Equal(apiImages[i].Url)) + Expect(img.Size).To(Equal(int(apiImages[i].Size))) + } + }) + + It("convertExternalImages should handle empty slice", func() { + agentImages := convertExternalImages([]*api.ExternalImage{}) + Expect(agentImages).To(BeEmpty()) + }) + + It("convertExternalImages should handle nil", func() { + agentImages := convertExternalImages(nil) + Expect(agentImages).To(BeEmpty()) + }) + }) + + Describe("Error mapping", func() { + var agent wasmMediaAgent + + It("should map API ErrNotFound to agents.ErrNotFound", func() { + err := agent.mapError(api.ErrNotFound) + Expect(err).To(Equal(agents.ErrNotFound)) + }) + + It("should map API ErrNotImplemented to agents.ErrNotFound", func() { + err := agent.mapError(api.ErrNotImplemented) + Expect(err).To(Equal(agents.ErrNotFound)) + }) + + It("should pass through other errors", func() { + testErr := errors.New("test error") + err := agent.mapError(testErr) + Expect(err).To(Equal(testErr)) + }) + + It("should handle nil error", func() { + err := agent.mapError(nil) + Expect(err).To(BeNil()) + }) + }) +}) diff --git a/plugins/adapter_scheduler_callback.go b/plugins/adapter_scheduler_callback.go new file mode 100644 index 0000000..64b7eef --- /dev/null +++ b/plugins/adapter_scheduler_callback.go @@ -0,0 +1,46 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/tetratelabs/wazero" +) + +// newWasmSchedulerCallback creates a new adapter for a SchedulerCallback plugin +func newWasmSchedulerCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { + loader, err := api.NewSchedulerCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) + if err != nil { + log.Error("Error creating scheduler callback plugin", "plugin", pluginID, "path", wasmPath, err) + return nil + } + return &wasmSchedulerCallback{ + baseCapability: newBaseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin]( + wasmPath, + pluginID, + CapabilitySchedulerCallback, + m.metrics, + loader, + func(ctx context.Context, l *api.SchedulerCallbackPlugin, path string) (api.SchedulerCallback, error) { + return l.Load(ctx, path) + }, + ), + } +} + +// wasmSchedulerCallback adapts a SchedulerCallback plugin +type wasmSchedulerCallback struct { + *baseCapability[api.SchedulerCallback, *api.SchedulerCallbackPlugin] +} + +func (w *wasmSchedulerCallback) OnSchedulerCallback(ctx context.Context, scheduleID string, payload []byte, isRecurring bool) error { + _, err := callMethod(ctx, w, "OnSchedulerCallback", func(inst api.SchedulerCallback) (*api.SchedulerCallbackResponse, error) { + return inst.OnSchedulerCallback(ctx, &api.SchedulerCallbackRequest{ + ScheduleId: scheduleID, + Payload: payload, + IsRecurring: isRecurring, + }) + }) + return err +} diff --git a/plugins/adapter_scrobbler.go b/plugins/adapter_scrobbler.go new file mode 100644 index 0000000..54c6af1 --- /dev/null +++ b/plugins/adapter_scrobbler.go @@ -0,0 +1,136 @@ +package plugins + +import ( + "context" + "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/plugins/api" + "github.com/tetratelabs/wazero" +) + +func newWasmScrobblerPlugin(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { + loader, err := api.NewScrobblerPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) + if err != nil { + log.Error("Error creating scrobbler service plugin", "plugin", pluginID, "path", wasmPath, err) + return nil + } + return &wasmScrobblerPlugin{ + baseCapability: newBaseCapability[api.Scrobbler, *api.ScrobblerPlugin]( + wasmPath, + pluginID, + CapabilityScrobbler, + m.metrics, + loader, + func(ctx context.Context, l *api.ScrobblerPlugin, path string) (api.Scrobbler, error) { + return l.Load(ctx, path) + }, + ), + } +} + +type wasmScrobblerPlugin struct { + *baseCapability[api.Scrobbler, *api.ScrobblerPlugin] +} + +func (w *wasmScrobblerPlugin) IsAuthorized(ctx context.Context, userId string) bool { + username, _ := request.UsernameFrom(ctx) + if username == "" { + u, ok := request.UserFrom(ctx) + if ok { + username = u.UserName + } + } + resp, err := callMethod(ctx, w, "IsAuthorized", func(inst api.Scrobbler) (*api.ScrobblerIsAuthorizedResponse, error) { + return inst.IsAuthorized(ctx, &api.ScrobblerIsAuthorizedRequest{ + UserId: userId, + Username: username, + }) + }) + if err != nil { + log.Warn("Error calling IsAuthorized", "userId", userId, "pluginID", w.id, err) + } + return err == nil && resp.Authorized +} + +func (w *wasmScrobblerPlugin) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error { + username, _ := request.UsernameFrom(ctx) + if username == "" { + u, ok := request.UserFrom(ctx) + if ok { + username = u.UserName + } + } + + trackInfo := w.toTrackInfo(track, position) + _, err := callMethod(ctx, w, "NowPlaying", func(inst api.Scrobbler) (struct{}, error) { + resp, err := inst.NowPlaying(ctx, &api.ScrobblerNowPlayingRequest{ + UserId: userId, + Username: username, + Track: trackInfo, + Timestamp: time.Now().Unix(), + }) + if err != nil { + return struct{}{}, err + } + if resp.Error != "" { + return struct{}{}, nil + } + return struct{}{}, nil + }) + return err +} + +func (w *wasmScrobblerPlugin) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error { + username, _ := request.UsernameFrom(ctx) + if username == "" { + u, ok := request.UserFrom(ctx) + if ok { + username = u.UserName + } + } + trackInfo := w.toTrackInfo(&s.MediaFile, 0) + _, err := callMethod(ctx, w, "Scrobble", func(inst api.Scrobbler) (struct{}, error) { + resp, err := inst.Scrobble(ctx, &api.ScrobblerScrobbleRequest{ + UserId: userId, + Username: username, + Track: trackInfo, + Timestamp: s.TimeStamp.Unix(), + }) + if err != nil { + return struct{}{}, err + } + if resp.Error != "" { + return struct{}{}, nil + } + return struct{}{}, nil + }) + return err +} + +func (w *wasmScrobblerPlugin) toTrackInfo(track *model.MediaFile, position int) *api.TrackInfo { + artists := make([]*api.Artist, 0, len(track.Participants[model.RoleArtist])) + + for _, a := range track.Participants[model.RoleArtist] { + artists = append(artists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID}) + } + albumArtists := make([]*api.Artist, 0, len(track.Participants[model.RoleAlbumArtist])) + for _, a := range track.Participants[model.RoleAlbumArtist] { + albumArtists = append(albumArtists, &api.Artist{Name: a.Name, Mbid: a.MbzArtistID}) + } + trackInfo := &api.TrackInfo{ + Id: track.ID, + Mbid: track.MbzRecordingID, + Name: track.Title, + Album: track.Album, + AlbumMbid: track.MbzAlbumID, + Artists: artists, + AlbumArtists: albumArtists, + Length: int32(track.Duration), + Position: int32(position), + } + return trackInfo +} diff --git a/plugins/adapter_websocket_callback.go b/plugins/adapter_websocket_callback.go new file mode 100644 index 0000000..83b8dd5 --- /dev/null +++ b/plugins/adapter_websocket_callback.go @@ -0,0 +1,35 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/tetratelabs/wazero" +) + +// newWasmWebSocketCallback creates a new adapter for a WebSocketCallback plugin +func newWasmWebSocketCallback(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin { + loader, err := api.NewWebSocketCallbackPlugin(context.Background(), api.WazeroRuntime(runtime), api.WazeroModuleConfig(mc)) + if err != nil { + log.Error("Error creating WebSocket callback plugin", "plugin", pluginID, "path", wasmPath, err) + return nil + } + return &wasmWebSocketCallback{ + baseCapability: newBaseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin]( + wasmPath, + pluginID, + CapabilityWebSocketCallback, + m.metrics, + loader, + func(ctx context.Context, l *api.WebSocketCallbackPlugin, path string) (api.WebSocketCallback, error) { + return l.Load(ctx, path) + }, + ), + } +} + +// wasmWebSocketCallback adapts a WebSocketCallback plugin +type wasmWebSocketCallback struct { + *baseCapability[api.WebSocketCallback, *api.WebSocketCallbackPlugin] +} diff --git a/plugins/api/api.pb.go b/plugins/api/api.pb.go new file mode 100644 index 0000000..b570d5c --- /dev/null +++ b/plugins/api/api.pb.go @@ -0,0 +1,1136 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: api/api.proto + +package api + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ArtistMBIDRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *ArtistMBIDRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistMBIDRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistMBIDRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type ArtistMBIDResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Mbid string `protobuf:"bytes,1,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistMBIDResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistMBIDResponse) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistURLRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistURLRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistURLRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistURLRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistURLRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistURLResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *ArtistURLResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistURLResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type ArtistBiographyRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistBiographyRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistBiographyRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistBiographyRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistBiographyRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistBiographyResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Biography string `protobuf:"bytes,1,opt,name=biography,proto3" json:"biography,omitempty"` +} + +func (x *ArtistBiographyResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistBiographyResponse) GetBiography() string { + if x != nil { + return x.Biography + } + return "" +} + +type ArtistSimilarRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` + Limit int32 `protobuf:"varint,4,opt,name=limit,proto3" json:"limit,omitempty"` +} + +func (x *ArtistSimilarRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistSimilarRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistSimilarRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistSimilarRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *ArtistSimilarRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type Artist struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *Artist) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *Artist) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Artist) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistSimilarResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Artists []*Artist `protobuf:"bytes,1,rep,name=artists,proto3" json:"artists,omitempty"` +} + +func (x *ArtistSimilarResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistSimilarResponse) GetArtists() []*Artist { + if x != nil { + return x.Artists + } + return nil +} + +type ArtistImageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *ArtistImageRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistImageRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistImageRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ArtistImageRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ExternalImage struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` +} + +func (x *ExternalImage) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ExternalImage) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ExternalImage) GetSize() int32 { + if x != nil { + return x.Size + } + return 0 +} + +type ArtistImageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Images []*ExternalImage `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"` +} + +func (x *ArtistImageResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistImageResponse) GetImages() []*ExternalImage { + if x != nil { + return x.Images + } + return nil +} + +type ArtistTopSongsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + ArtistName string `protobuf:"bytes,2,opt,name=artistName,proto3" json:"artistName,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` + Count int32 `protobuf:"varint,4,opt,name=count,proto3" json:"count,omitempty"` +} + +func (x *ArtistTopSongsRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistTopSongsRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ArtistTopSongsRequest) GetArtistName() string { + if x != nil { + return x.ArtistName + } + return "" +} + +func (x *ArtistTopSongsRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *ArtistTopSongsRequest) GetCount() int32 { + if x != nil { + return x.Count + } + return 0 +} + +type Song struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *Song) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *Song) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Song) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type ArtistTopSongsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Songs []*Song `protobuf:"bytes,1,rep,name=songs,proto3" json:"songs,omitempty"` +} + +func (x *ArtistTopSongsResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ArtistTopSongsResponse) GetSongs() []*Song { + if x != nil { + return x.Songs + } + return nil +} + +type AlbumInfoRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Artist string `protobuf:"bytes,2,opt,name=artist,proto3" json:"artist,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *AlbumInfoRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumInfoRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AlbumInfoRequest) GetArtist() string { + if x != nil { + return x.Artist + } + return "" +} + +func (x *AlbumInfoRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type AlbumInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *AlbumInfo) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AlbumInfo) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *AlbumInfo) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AlbumInfo) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type AlbumInfoResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Info *AlbumInfo `protobuf:"bytes,1,opt,name=info,proto3" json:"info,omitempty"` +} + +func (x *AlbumInfoResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumInfoResponse) GetInfo() *AlbumInfo { + if x != nil { + return x.Info + } + return nil +} + +type AlbumImagesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Artist string `protobuf:"bytes,2,opt,name=artist,proto3" json:"artist,omitempty"` + Mbid string `protobuf:"bytes,3,opt,name=mbid,proto3" json:"mbid,omitempty"` +} + +func (x *AlbumImagesRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumImagesRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AlbumImagesRequest) GetArtist() string { + if x != nil { + return x.Artist + } + return "" +} + +func (x *AlbumImagesRequest) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +type AlbumImagesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Images []*ExternalImage `protobuf:"bytes,1,rep,name=images,proto3" json:"images,omitempty"` +} + +func (x *AlbumImagesResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *AlbumImagesResponse) GetImages() []*ExternalImage { + if x != nil { + return x.Images + } + return nil +} + +type ScrobblerIsAuthorizedRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` +} + +func (x *ScrobblerIsAuthorizedRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerIsAuthorizedRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ScrobblerIsAuthorizedRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +type ScrobblerIsAuthorizedResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Authorized bool `protobuf:"varint,1,opt,name=authorized,proto3" json:"authorized,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ScrobblerIsAuthorizedResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerIsAuthorizedResponse) GetAuthorized() bool { + if x != nil { + return x.Authorized + } + return false +} + +func (x *ScrobblerIsAuthorizedResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type TrackInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Mbid string `protobuf:"bytes,2,opt,name=mbid,proto3" json:"mbid,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Album string `protobuf:"bytes,4,opt,name=album,proto3" json:"album,omitempty"` + AlbumMbid string `protobuf:"bytes,5,opt,name=album_mbid,json=albumMbid,proto3" json:"album_mbid,omitempty"` + Artists []*Artist `protobuf:"bytes,6,rep,name=artists,proto3" json:"artists,omitempty"` + AlbumArtists []*Artist `protobuf:"bytes,7,rep,name=album_artists,json=albumArtists,proto3" json:"album_artists,omitempty"` + Length int32 `protobuf:"varint,8,opt,name=length,proto3" json:"length,omitempty"` // seconds + Position int32 `protobuf:"varint,9,opt,name=position,proto3" json:"position,omitempty"` // seconds +} + +func (x *TrackInfo) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *TrackInfo) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *TrackInfo) GetMbid() string { + if x != nil { + return x.Mbid + } + return "" +} + +func (x *TrackInfo) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TrackInfo) GetAlbum() string { + if x != nil { + return x.Album + } + return "" +} + +func (x *TrackInfo) GetAlbumMbid() string { + if x != nil { + return x.AlbumMbid + } + return "" +} + +func (x *TrackInfo) GetArtists() []*Artist { + if x != nil { + return x.Artists + } + return nil +} + +func (x *TrackInfo) GetAlbumArtists() []*Artist { + if x != nil { + return x.AlbumArtists + } + return nil +} + +func (x *TrackInfo) GetLength() int32 { + if x != nil { + return x.Length + } + return 0 +} + +func (x *TrackInfo) GetPosition() int32 { + if x != nil { + return x.Position + } + return 0 +} + +type ScrobblerNowPlayingRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Track *TrackInfo `protobuf:"bytes,3,opt,name=track,proto3" json:"track,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *ScrobblerNowPlayingRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerNowPlayingRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ScrobblerNowPlayingRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ScrobblerNowPlayingRequest) GetTrack() *TrackInfo { + if x != nil { + return x.Track + } + return nil +} + +func (x *ScrobblerNowPlayingRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type ScrobblerNowPlayingResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ScrobblerNowPlayingResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerNowPlayingResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type ScrobblerScrobbleRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + Track *TrackInfo `protobuf:"bytes,3,opt,name=track,proto3" json:"track,omitempty"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *ScrobblerScrobbleRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerScrobbleRequest) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *ScrobblerScrobbleRequest) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ScrobblerScrobbleRequest) GetTrack() *TrackInfo { + if x != nil { + return x.Track + } + return nil +} + +func (x *ScrobblerScrobbleRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +type ScrobblerScrobbleResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ScrobblerScrobbleResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScrobblerScrobbleResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SchedulerCallbackRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the scheduled job that triggered this callback + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // The data passed when the job was scheduled + IsRecurring bool `protobuf:"varint,3,opt,name=is_recurring,json=isRecurring,proto3" json:"is_recurring,omitempty"` // Whether this is from a recurring schedule (cron job) +} + +func (x *SchedulerCallbackRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SchedulerCallbackRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +func (x *SchedulerCallbackRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *SchedulerCallbackRequest) GetIsRecurring() bool { + if x != nil { + return x.IsRecurring + } + return false +} + +type SchedulerCallbackResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` // Error message if the callback failed +} + +func (x *SchedulerCallbackResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SchedulerCallbackResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type InitRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin +} + +func (x *InitRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *InitRequest) GetConfig() map[string]string { + if x != nil { + return x.Config + } + return nil +} + +type InitResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` // Error message if initialization failed +} + +func (x *InitResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *InitResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type OnTextMessageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *OnTextMessageRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnTextMessageRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnTextMessageRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type OnTextMessageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnTextMessageResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type OnBinaryMessageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *OnBinaryMessageRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnBinaryMessageRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnBinaryMessageRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type OnBinaryMessageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnBinaryMessageResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type OnErrorRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *OnErrorRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnErrorRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnErrorRequest) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type OnErrorResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnErrorResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type OnCloseRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *OnCloseRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *OnCloseRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *OnCloseRequest) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *OnCloseRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type OnCloseResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *OnCloseResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +// go:plugin type=plugin version=1 +type MetadataAgent interface { + // Artist metadata methods + GetArtistMBID(context.Context, *ArtistMBIDRequest) (*ArtistMBIDResponse, error) + GetArtistURL(context.Context, *ArtistURLRequest) (*ArtistURLResponse, error) + GetArtistBiography(context.Context, *ArtistBiographyRequest) (*ArtistBiographyResponse, error) + GetSimilarArtists(context.Context, *ArtistSimilarRequest) (*ArtistSimilarResponse, error) + GetArtistImages(context.Context, *ArtistImageRequest) (*ArtistImageResponse, error) + GetArtistTopSongs(context.Context, *ArtistTopSongsRequest) (*ArtistTopSongsResponse, error) + // Album metadata methods + GetAlbumInfo(context.Context, *AlbumInfoRequest) (*AlbumInfoResponse, error) + GetAlbumImages(context.Context, *AlbumImagesRequest) (*AlbumImagesResponse, error) +} + +// go:plugin type=plugin version=1 +type Scrobbler interface { + IsAuthorized(context.Context, *ScrobblerIsAuthorizedRequest) (*ScrobblerIsAuthorizedResponse, error) + NowPlaying(context.Context, *ScrobblerNowPlayingRequest) (*ScrobblerNowPlayingResponse, error) + Scrobble(context.Context, *ScrobblerScrobbleRequest) (*ScrobblerScrobbleResponse, error) +} + +// go:plugin type=plugin version=1 +type SchedulerCallback interface { + OnSchedulerCallback(context.Context, *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) +} + +// go:plugin type=plugin version=1 +type LifecycleManagement interface { + OnInit(context.Context, *InitRequest) (*InitResponse, error) +} + +// go:plugin type=plugin version=1 +type WebSocketCallback interface { + // Called when a text message is received + OnTextMessage(context.Context, *OnTextMessageRequest) (*OnTextMessageResponse, error) + // Called when a binary message is received + OnBinaryMessage(context.Context, *OnBinaryMessageRequest) (*OnBinaryMessageResponse, error) + // Called when an error occurs + OnError(context.Context, *OnErrorRequest) (*OnErrorResponse, error) + // Called when the connection is closed + OnClose(context.Context, *OnCloseRequest) (*OnCloseResponse, error) +} diff --git a/plugins/api/api.proto b/plugins/api/api.proto new file mode 100644 index 0000000..7929ff9 --- /dev/null +++ b/plugins/api/api.proto @@ -0,0 +1,246 @@ +syntax = "proto3"; + +package api; + +option go_package = "github.com/navidrome/navidrome/plugins/api;api"; + +// go:plugin type=plugin version=1 +service MetadataAgent { + // Artist metadata methods + rpc GetArtistMBID(ArtistMBIDRequest) returns (ArtistMBIDResponse); + rpc GetArtistURL(ArtistURLRequest) returns (ArtistURLResponse); + rpc GetArtistBiography(ArtistBiographyRequest) returns (ArtistBiographyResponse); + rpc GetSimilarArtists(ArtistSimilarRequest) returns (ArtistSimilarResponse); + rpc GetArtistImages(ArtistImageRequest) returns (ArtistImageResponse); + rpc GetArtistTopSongs(ArtistTopSongsRequest) returns (ArtistTopSongsResponse); + + // Album metadata methods + rpc GetAlbumInfo(AlbumInfoRequest) returns (AlbumInfoResponse); + rpc GetAlbumImages(AlbumImagesRequest) returns (AlbumImagesResponse); +} + +message ArtistMBIDRequest { + string id = 1; + string name = 2; +} + +message ArtistMBIDResponse { + string mbid = 1; +} + +message ArtistURLRequest { + string id = 1; + string name = 2; + string mbid = 3; +} + +message ArtistURLResponse { + string url = 1; +} + +message ArtistBiographyRequest { + string id = 1; + string name = 2; + string mbid = 3; +} + +message ArtistBiographyResponse { + string biography = 1; +} + +message ArtistSimilarRequest { + string id = 1; + string name = 2; + string mbid = 3; + int32 limit = 4; +} + +message Artist { + string name = 1; + string mbid = 2; +} + +message ArtistSimilarResponse { + repeated Artist artists = 1; +} + +message ArtistImageRequest { + string id = 1; + string name = 2; + string mbid = 3; +} + +message ExternalImage { + string url = 1; + int32 size = 2; +} + +message ArtistImageResponse { + repeated ExternalImage images = 1; +} + +message ArtistTopSongsRequest { + string id = 1; + string artistName = 2; + string mbid = 3; + int32 count = 4; +} + +message Song { + string name = 1; + string mbid = 2; +} + +message ArtistTopSongsResponse { + repeated Song songs = 1; +} + +message AlbumInfoRequest { + string name = 1; + string artist = 2; + string mbid = 3; +} + +message AlbumInfo { + string name = 1; + string mbid = 2; + string description = 3; + string url = 4; +} + +message AlbumInfoResponse { + AlbumInfo info = 1; +} + +message AlbumImagesRequest { + string name = 1; + string artist = 2; + string mbid = 3; +} + +message AlbumImagesResponse { + repeated ExternalImage images = 1; +} + +// go:plugin type=plugin version=1 +service Scrobbler { + rpc IsAuthorized(ScrobblerIsAuthorizedRequest) returns (ScrobblerIsAuthorizedResponse); + rpc NowPlaying(ScrobblerNowPlayingRequest) returns (ScrobblerNowPlayingResponse); + rpc Scrobble(ScrobblerScrobbleRequest) returns (ScrobblerScrobbleResponse); +} + +message ScrobblerIsAuthorizedRequest { + string user_id = 1; + string username = 2; +} + +message ScrobblerIsAuthorizedResponse { + bool authorized = 1; + string error = 2; +} + +message TrackInfo { + string id = 1; + string mbid = 2; + string name = 3; + string album = 4; + string album_mbid = 5; + repeated Artist artists = 6; + repeated Artist album_artists = 7; + int32 length = 8; // seconds + int32 position = 9; // seconds +} + +message ScrobblerNowPlayingRequest { + string user_id = 1; + string username = 2; + TrackInfo track = 3; + int64 timestamp = 4; +} + +message ScrobblerNowPlayingResponse { + string error = 1; +} + +message ScrobblerScrobbleRequest { + string user_id = 1; + string username = 2; + TrackInfo track = 3; + int64 timestamp = 4; +} + +message ScrobblerScrobbleResponse { + string error = 1; +} + +// go:plugin type=plugin version=1 +service SchedulerCallback { + rpc OnSchedulerCallback(SchedulerCallbackRequest) returns (SchedulerCallbackResponse); +} + +message SchedulerCallbackRequest { + string schedule_id = 1; // ID of the scheduled job that triggered this callback + bytes payload = 2; // The data passed when the job was scheduled + bool is_recurring = 3; // Whether this is from a recurring schedule (cron job) +} + +message SchedulerCallbackResponse { + string error = 1; // Error message if the callback failed +} + +// go:plugin type=plugin version=1 +service LifecycleManagement { + rpc OnInit(InitRequest) returns (InitResponse); +} + +message InitRequest { + map config = 1; // Configuration specific to this plugin +} + +message InitResponse { + string error = 1; // Error message if initialization failed +} + +// go:plugin type=plugin version=1 +service WebSocketCallback { + // Called when a text message is received + rpc OnTextMessage(OnTextMessageRequest) returns (OnTextMessageResponse); + + // Called when a binary message is received + rpc OnBinaryMessage(OnBinaryMessageRequest) returns (OnBinaryMessageResponse); + + // Called when an error occurs + rpc OnError(OnErrorRequest) returns (OnErrorResponse); + + // Called when the connection is closed + rpc OnClose(OnCloseRequest) returns (OnCloseResponse); +} + +message OnTextMessageRequest { + string connection_id = 1; + string message = 2; +} + +message OnTextMessageResponse {} + +message OnBinaryMessageRequest { + string connection_id = 1; + bytes data = 2; +} + +message OnBinaryMessageResponse {} + +message OnErrorRequest { + string connection_id = 1; + string error = 2; +} + +message OnErrorResponse {} + +message OnCloseRequest { + string connection_id = 1; + int32 code = 2; + string reason = 3; +} + +message OnCloseResponse {} \ No newline at end of file diff --git a/plugins/api/api_host.pb.go b/plugins/api/api_host.pb.go new file mode 100644 index 0000000..55e648c --- /dev/null +++ b/plugins/api/api_host.pb.go @@ -0,0 +1,1688 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: api/api.proto + +package api + +import ( + context "context" + errors "errors" + fmt "fmt" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" + sys "github.com/tetratelabs/wazero/sys" + os "os" +) + +const MetadataAgentPluginAPIVersion = 1 + +type MetadataAgentPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewMetadataAgentPlugin(ctx context.Context, opts ...wazeroConfigOption) (*MetadataAgentPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &MetadataAgentPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type metadataAgent interface { + Close(ctx context.Context) error + MetadataAgent +} + +func (p *MetadataAgentPlugin) Load(ctx context.Context, pluginPath string) (metadataAgent, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("metadata_agent_api_version") + if apiVersion == nil { + return nil, errors.New("metadata_agent_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid metadata_agent_api_version signature") + } + if results[0] != MetadataAgentPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", MetadataAgentPluginAPIVersion, results[0]) + } + + getartistmbid := module.ExportedFunction("metadata_agent_get_artist_mbid") + if getartistmbid == nil { + return nil, errors.New("metadata_agent_get_artist_mbid is not exported") + } + getartisturl := module.ExportedFunction("metadata_agent_get_artist_url") + if getartisturl == nil { + return nil, errors.New("metadata_agent_get_artist_url is not exported") + } + getartistbiography := module.ExportedFunction("metadata_agent_get_artist_biography") + if getartistbiography == nil { + return nil, errors.New("metadata_agent_get_artist_biography is not exported") + } + getsimilarartists := module.ExportedFunction("metadata_agent_get_similar_artists") + if getsimilarartists == nil { + return nil, errors.New("metadata_agent_get_similar_artists is not exported") + } + getartistimages := module.ExportedFunction("metadata_agent_get_artist_images") + if getartistimages == nil { + return nil, errors.New("metadata_agent_get_artist_images is not exported") + } + getartisttopsongs := module.ExportedFunction("metadata_agent_get_artist_top_songs") + if getartisttopsongs == nil { + return nil, errors.New("metadata_agent_get_artist_top_songs is not exported") + } + getalbuminfo := module.ExportedFunction("metadata_agent_get_album_info") + if getalbuminfo == nil { + return nil, errors.New("metadata_agent_get_album_info is not exported") + } + getalbumimages := module.ExportedFunction("metadata_agent_get_album_images") + if getalbumimages == nil { + return nil, errors.New("metadata_agent_get_album_images is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &metadataAgentPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + getartistmbid: getartistmbid, + getartisturl: getartisturl, + getartistbiography: getartistbiography, + getsimilarartists: getsimilarartists, + getartistimages: getartistimages, + getartisttopsongs: getartisttopsongs, + getalbuminfo: getalbuminfo, + getalbumimages: getalbumimages, + }, nil +} + +func (p *metadataAgentPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type metadataAgentPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + getartistmbid api.Function + getartisturl api.Function + getartistbiography api.Function + getsimilarartists api.Function + getartistimages api.Function + getartisttopsongs api.Function + getalbuminfo api.Function + getalbumimages api.Function +} + +func (p *metadataAgentPlugin) GetArtistMBID(ctx context.Context, request *ArtistMBIDRequest) (*ArtistMBIDResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartistmbid.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistMBIDResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistURL(ctx context.Context, request *ArtistURLRequest) (*ArtistURLResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartisturl.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistURLResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistBiography(ctx context.Context, request *ArtistBiographyRequest) (*ArtistBiographyResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartistbiography.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistBiographyResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetSimilarArtists(ctx context.Context, request *ArtistSimilarRequest) (*ArtistSimilarResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getsimilarartists.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistSimilarResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistImages(ctx context.Context, request *ArtistImageRequest) (*ArtistImageResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartistimages.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistImageResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetArtistTopSongs(ctx context.Context, request *ArtistTopSongsRequest) (*ArtistTopSongsResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getartisttopsongs.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ArtistTopSongsResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetAlbumInfo(ctx context.Context, request *AlbumInfoRequest) (*AlbumInfoResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getalbuminfo.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(AlbumInfoResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *metadataAgentPlugin) GetAlbumImages(ctx context.Context, request *AlbumImagesRequest) (*AlbumImagesResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.getalbumimages.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(AlbumImagesResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const ScrobblerPluginAPIVersion = 1 + +type ScrobblerPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewScrobblerPlugin(ctx context.Context, opts ...wazeroConfigOption) (*ScrobblerPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &ScrobblerPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type scrobbler interface { + Close(ctx context.Context) error + Scrobbler +} + +func (p *ScrobblerPlugin) Load(ctx context.Context, pluginPath string) (scrobbler, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("scrobbler_api_version") + if apiVersion == nil { + return nil, errors.New("scrobbler_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid scrobbler_api_version signature") + } + if results[0] != ScrobblerPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", ScrobblerPluginAPIVersion, results[0]) + } + + isauthorized := module.ExportedFunction("scrobbler_is_authorized") + if isauthorized == nil { + return nil, errors.New("scrobbler_is_authorized is not exported") + } + nowplaying := module.ExportedFunction("scrobbler_now_playing") + if nowplaying == nil { + return nil, errors.New("scrobbler_now_playing is not exported") + } + scrobble := module.ExportedFunction("scrobbler_scrobble") + if scrobble == nil { + return nil, errors.New("scrobbler_scrobble is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &scrobblerPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + isauthorized: isauthorized, + nowplaying: nowplaying, + scrobble: scrobble, + }, nil +} + +func (p *scrobblerPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type scrobblerPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + isauthorized api.Function + nowplaying api.Function + scrobble api.Function +} + +func (p *scrobblerPlugin) IsAuthorized(ctx context.Context, request *ScrobblerIsAuthorizedRequest) (*ScrobblerIsAuthorizedResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.isauthorized.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ScrobblerIsAuthorizedResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *scrobblerPlugin) NowPlaying(ctx context.Context, request *ScrobblerNowPlayingRequest) (*ScrobblerNowPlayingResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.nowplaying.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ScrobblerNowPlayingResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *scrobblerPlugin) Scrobble(ctx context.Context, request *ScrobblerScrobbleRequest) (*ScrobblerScrobbleResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.scrobble.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(ScrobblerScrobbleResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const SchedulerCallbackPluginAPIVersion = 1 + +type SchedulerCallbackPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewSchedulerCallbackPlugin(ctx context.Context, opts ...wazeroConfigOption) (*SchedulerCallbackPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &SchedulerCallbackPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type schedulerCallback interface { + Close(ctx context.Context) error + SchedulerCallback +} + +func (p *SchedulerCallbackPlugin) Load(ctx context.Context, pluginPath string) (schedulerCallback, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("scheduler_callback_api_version") + if apiVersion == nil { + return nil, errors.New("scheduler_callback_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid scheduler_callback_api_version signature") + } + if results[0] != SchedulerCallbackPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", SchedulerCallbackPluginAPIVersion, results[0]) + } + + onschedulercallback := module.ExportedFunction("scheduler_callback_on_scheduler_callback") + if onschedulercallback == nil { + return nil, errors.New("scheduler_callback_on_scheduler_callback is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &schedulerCallbackPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + onschedulercallback: onschedulercallback, + }, nil +} + +func (p *schedulerCallbackPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type schedulerCallbackPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + onschedulercallback api.Function +} + +func (p *schedulerCallbackPlugin) OnSchedulerCallback(ctx context.Context, request *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onschedulercallback.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(SchedulerCallbackResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const LifecycleManagementPluginAPIVersion = 1 + +type LifecycleManagementPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewLifecycleManagementPlugin(ctx context.Context, opts ...wazeroConfigOption) (*LifecycleManagementPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &LifecycleManagementPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type lifecycleManagement interface { + Close(ctx context.Context) error + LifecycleManagement +} + +func (p *LifecycleManagementPlugin) Load(ctx context.Context, pluginPath string) (lifecycleManagement, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("lifecycle_management_api_version") + if apiVersion == nil { + return nil, errors.New("lifecycle_management_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid lifecycle_management_api_version signature") + } + if results[0] != LifecycleManagementPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", LifecycleManagementPluginAPIVersion, results[0]) + } + + oninit := module.ExportedFunction("lifecycle_management_on_init") + if oninit == nil { + return nil, errors.New("lifecycle_management_on_init is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &lifecycleManagementPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + oninit: oninit, + }, nil +} + +func (p *lifecycleManagementPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type lifecycleManagementPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + oninit api.Function +} + +func (p *lifecycleManagementPlugin) OnInit(ctx context.Context, request *InitRequest) (*InitResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.oninit.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(InitResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} + +const WebSocketCallbackPluginAPIVersion = 1 + +type WebSocketCallbackPlugin struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func NewWebSocketCallbackPlugin(ctx context.Context, opts ...wazeroConfigOption) (*WebSocketCallbackPlugin, error) { + o := &WazeroConfig{ + newRuntime: DefaultWazeroRuntime(), + moduleConfig: wazero.NewModuleConfig().WithStartFunctions("_initialize"), + } + + for _, opt := range opts { + opt(o) + } + + return &WebSocketCallbackPlugin{ + newRuntime: o.newRuntime, + moduleConfig: o.moduleConfig, + }, nil +} + +type webSocketCallback interface { + Close(ctx context.Context) error + WebSocketCallback +} + +func (p *WebSocketCallbackPlugin) Load(ctx context.Context, pluginPath string) (webSocketCallback, error) { + b, err := os.ReadFile(pluginPath) + if err != nil { + return nil, err + } + + // Create a new runtime so that multiple modules will not conflict + r, err := p.newRuntime(ctx) + if err != nil { + return nil, err + } + + // Compile the WebAssembly module using the default configuration. + code, err := r.CompileModule(ctx, b) + if err != nil { + return nil, err + } + + // InstantiateModule runs the "_start" function, WASI's "main". + module, err := r.InstantiateModule(ctx, code, p.moduleConfig) + if err != nil { + // Note: Most compilers do not exit the module after running "_start", + // unless there was an Error. This allows you to call exported functions. + if exitErr, ok := err.(*sys.ExitError); ok && exitErr.ExitCode() != 0 { + return nil, fmt.Errorf("unexpected exit_code: %d", exitErr.ExitCode()) + } else if !ok { + return nil, err + } + } + + // Compare API versions with the loading plugin + apiVersion := module.ExportedFunction("web_socket_callback_api_version") + if apiVersion == nil { + return nil, errors.New("web_socket_callback_api_version is not exported") + } + results, err := apiVersion.Call(ctx) + if err != nil { + return nil, err + } else if len(results) != 1 { + return nil, errors.New("invalid web_socket_callback_api_version signature") + } + if results[0] != WebSocketCallbackPluginAPIVersion { + return nil, fmt.Errorf("API version mismatch, host: %d, plugin: %d", WebSocketCallbackPluginAPIVersion, results[0]) + } + + ontextmessage := module.ExportedFunction("web_socket_callback_on_text_message") + if ontextmessage == nil { + return nil, errors.New("web_socket_callback_on_text_message is not exported") + } + onbinarymessage := module.ExportedFunction("web_socket_callback_on_binary_message") + if onbinarymessage == nil { + return nil, errors.New("web_socket_callback_on_binary_message is not exported") + } + onerror := module.ExportedFunction("web_socket_callback_on_error") + if onerror == nil { + return nil, errors.New("web_socket_callback_on_error is not exported") + } + onclose := module.ExportedFunction("web_socket_callback_on_close") + if onclose == nil { + return nil, errors.New("web_socket_callback_on_close is not exported") + } + + malloc := module.ExportedFunction("malloc") + if malloc == nil { + return nil, errors.New("malloc is not exported") + } + + free := module.ExportedFunction("free") + if free == nil { + return nil, errors.New("free is not exported") + } + return &webSocketCallbackPlugin{ + runtime: r, + module: module, + malloc: malloc, + free: free, + ontextmessage: ontextmessage, + onbinarymessage: onbinarymessage, + onerror: onerror, + onclose: onclose, + }, nil +} + +func (p *webSocketCallbackPlugin) Close(ctx context.Context) (err error) { + if r := p.runtime; r != nil { + r.Close(ctx) + } + return +} + +type webSocketCallbackPlugin struct { + runtime wazero.Runtime + module api.Module + malloc api.Function + free api.Function + ontextmessage api.Function + onbinarymessage api.Function + onerror api.Function + onclose api.Function +} + +func (p *webSocketCallbackPlugin) OnTextMessage(ctx context.Context, request *OnTextMessageRequest) (*OnTextMessageResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.ontextmessage.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnTextMessageResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *webSocketCallbackPlugin) OnBinaryMessage(ctx context.Context, request *OnBinaryMessageRequest) (*OnBinaryMessageResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onbinarymessage.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnBinaryMessageResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *webSocketCallbackPlugin) OnError(ctx context.Context, request *OnErrorRequest) (*OnErrorResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onerror.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnErrorResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} +func (p *webSocketCallbackPlugin) OnClose(ctx context.Context, request *OnCloseRequest) (*OnCloseResponse, error) { + data, err := request.MarshalVT() + if err != nil { + return nil, err + } + dataSize := uint64(len(data)) + + var dataPtr uint64 + // If the input data is not empty, we must allocate the in-Wasm memory to store it, and pass to the plugin. + if dataSize != 0 { + results, err := p.malloc.Call(ctx, dataSize) + if err != nil { + return nil, err + } + dataPtr = results[0] + // This pointer is managed by the Wasm module, which is unaware of external usage. + // So, we have to free it when finished + defer p.free.Call(ctx, dataPtr) + + // The pointer is a linear memory offset, which is where we write the name. + if !p.module.Memory().Write(uint32(dataPtr), data) { + return nil, fmt.Errorf("Memory.Write(%d, %d) out of range of memory size %d", dataPtr, dataSize, p.module.Memory().Size()) + } + } + + ptrSize, err := p.onclose.Call(ctx, dataPtr, dataSize) + if err != nil { + return nil, err + } + + resPtr := uint32(ptrSize[0] >> 32) + resSize := uint32(ptrSize[0]) + var isErrResponse bool + if (resSize & (1 << 31)) > 0 { + isErrResponse = true + resSize &^= (1 << 31) + } + + // We don't need the memory after deserialization: make sure it is freed. + if resPtr != 0 { + defer p.free.Call(ctx, uint64(resPtr)) + } + + // The pointer is a linear memory offset, which is where we write the name. + bytes, ok := p.module.Memory().Read(resPtr, resSize) + if !ok { + return nil, fmt.Errorf("Memory.Read(%d, %d) out of range of memory size %d", + resPtr, resSize, p.module.Memory().Size()) + } + + if isErrResponse { + return nil, errors.New(string(bytes)) + } + + response := new(OnCloseResponse) + if err = response.UnmarshalVT(bytes); err != nil { + return nil, err + } + + return response, nil +} diff --git a/plugins/api/api_options.pb.go b/plugins/api/api_options.pb.go new file mode 100644 index 0000000..430bf0a --- /dev/null +++ b/plugins/api/api_options.pb.go @@ -0,0 +1,47 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: api/api.proto + +package api + +import ( + context "context" + wazero "github.com/tetratelabs/wazero" + wasi_snapshot_preview1 "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +type wazeroConfigOption func(plugin *WazeroConfig) + +type WazeroNewRuntime func(context.Context) (wazero.Runtime, error) + +type WazeroConfig struct { + newRuntime func(context.Context) (wazero.Runtime, error) + moduleConfig wazero.ModuleConfig +} + +func WazeroRuntime(newRuntime WazeroNewRuntime) wazeroConfigOption { + return func(h *WazeroConfig) { + h.newRuntime = newRuntime + } +} + +func DefaultWazeroRuntime() WazeroNewRuntime { + return func(ctx context.Context) (wazero.Runtime, error) { + r := wazero.NewRuntime(ctx) + if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { + return nil, err + } + + return r, nil + } +} + +func WazeroModuleConfig(moduleConfig wazero.ModuleConfig) wazeroConfigOption { + return func(h *WazeroConfig) { + h.moduleConfig = moduleConfig + } +} diff --git a/plugins/api/api_plugin.pb.go b/plugins/api/api_plugin.pb.go new file mode 100644 index 0000000..0a022be --- /dev/null +++ b/plugins/api/api_plugin.pb.go @@ -0,0 +1,487 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: api/api.proto + +package api + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" +) + +const MetadataAgentPluginAPIVersion = 1 + +//go:wasmexport metadata_agent_api_version +func _metadata_agent_api_version() uint64 { + return MetadataAgentPluginAPIVersion +} + +var metadataAgent MetadataAgent + +func RegisterMetadataAgent(p MetadataAgent) { + metadataAgent = p +} + +//go:wasmexport metadata_agent_get_artist_mbid +func _metadata_agent_get_artist_mbid(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ArtistMBIDRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetArtistMBID(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport metadata_agent_get_artist_url +func _metadata_agent_get_artist_url(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ArtistURLRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetArtistURL(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport metadata_agent_get_artist_biography +func _metadata_agent_get_artist_biography(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ArtistBiographyRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetArtistBiography(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport metadata_agent_get_similar_artists +func _metadata_agent_get_similar_artists(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ArtistSimilarRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetSimilarArtists(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport metadata_agent_get_artist_images +func _metadata_agent_get_artist_images(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ArtistImageRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetArtistImages(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport metadata_agent_get_artist_top_songs +func _metadata_agent_get_artist_top_songs(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ArtistTopSongsRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetArtistTopSongs(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport metadata_agent_get_album_info +func _metadata_agent_get_album_info(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(AlbumInfoRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetAlbumInfo(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport metadata_agent_get_album_images +func _metadata_agent_get_album_images(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(AlbumImagesRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := metadataAgent.GetAlbumImages(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +const ScrobblerPluginAPIVersion = 1 + +//go:wasmexport scrobbler_api_version +func _scrobbler_api_version() uint64 { + return ScrobblerPluginAPIVersion +} + +var scrobbler Scrobbler + +func RegisterScrobbler(p Scrobbler) { + scrobbler = p +} + +//go:wasmexport scrobbler_is_authorized +func _scrobbler_is_authorized(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ScrobblerIsAuthorizedRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := scrobbler.IsAuthorized(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport scrobbler_now_playing +func _scrobbler_now_playing(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ScrobblerNowPlayingRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := scrobbler.NowPlaying(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport scrobbler_scrobble +func _scrobbler_scrobble(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(ScrobblerScrobbleRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := scrobbler.Scrobble(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +const SchedulerCallbackPluginAPIVersion = 1 + +//go:wasmexport scheduler_callback_api_version +func _scheduler_callback_api_version() uint64 { + return SchedulerCallbackPluginAPIVersion +} + +var schedulerCallback SchedulerCallback + +func RegisterSchedulerCallback(p SchedulerCallback) { + schedulerCallback = p +} + +//go:wasmexport scheduler_callback_on_scheduler_callback +func _scheduler_callback_on_scheduler_callback(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(SchedulerCallbackRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := schedulerCallback.OnSchedulerCallback(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +const LifecycleManagementPluginAPIVersion = 1 + +//go:wasmexport lifecycle_management_api_version +func _lifecycle_management_api_version() uint64 { + return LifecycleManagementPluginAPIVersion +} + +var lifecycleManagement LifecycleManagement + +func RegisterLifecycleManagement(p LifecycleManagement) { + lifecycleManagement = p +} + +//go:wasmexport lifecycle_management_on_init +func _lifecycle_management_on_init(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(InitRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := lifecycleManagement.OnInit(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +const WebSocketCallbackPluginAPIVersion = 1 + +//go:wasmexport web_socket_callback_api_version +func _web_socket_callback_api_version() uint64 { + return WebSocketCallbackPluginAPIVersion +} + +var webSocketCallback WebSocketCallback + +func RegisterWebSocketCallback(p WebSocketCallback) { + webSocketCallback = p +} + +//go:wasmexport web_socket_callback_on_text_message +func _web_socket_callback_on_text_message(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(OnTextMessageRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := webSocketCallback.OnTextMessage(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport web_socket_callback_on_binary_message +func _web_socket_callback_on_binary_message(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(OnBinaryMessageRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := webSocketCallback.OnBinaryMessage(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport web_socket_callback_on_error +func _web_socket_callback_on_error(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(OnErrorRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := webSocketCallback.OnError(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} + +//go:wasmexport web_socket_callback_on_close +func _web_socket_callback_on_close(ptr, size uint32) uint64 { + b := wasm.PtrToByte(ptr, size) + req := new(OnCloseRequest) + if err := req.UnmarshalVT(b); err != nil { + return 0 + } + response, err := webSocketCallback.OnClose(context.Background(), req) + if err != nil { + ptr, size = wasm.ByteToPtr([]byte(err.Error())) + return (uint64(ptr) << uint64(32)) | uint64(size) | + // Indicate that this is the error string by setting the 32-th bit, assuming that + // no data exceeds 31-bit size (2 GiB). + (1 << 31) + } + + b, err = response.MarshalVT() + if err != nil { + return 0 + } + ptr, size = wasm.ByteToPtr(b) + return (uint64(ptr) << uint64(32)) | uint64(size) +} diff --git a/plugins/api/api_plugin_dev.go b/plugins/api/api_plugin_dev.go new file mode 100644 index 0000000..ed5a064 --- /dev/null +++ b/plugins/api/api_plugin_dev.go @@ -0,0 +1,34 @@ +//go:build !wasip1 + +package api + +import "github.com/navidrome/navidrome/plugins/host/scheduler" + +// This file exists to provide stubs for the plugin registration functions when building for non-WASM targets. +// This is useful for testing and development purposes, as it allows you to build and run your plugin code +// without having to compile it to WASM. +// In a real-world scenario, you would compile your plugin to WASM and use the generated registration functions. + +func RegisterMetadataAgent(MetadataAgent) { + panic("not implemented") +} + +func RegisterScrobbler(Scrobbler) { + panic("not implemented") +} + +func RegisterSchedulerCallback(SchedulerCallback) { + panic("not implemented") +} + +func RegisterLifecycleManagement(LifecycleManagement) { + panic("not implemented") +} + +func RegisterWebSocketCallback(WebSocketCallback) { + panic("not implemented") +} + +func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService { + panic("not implemented") +} diff --git a/plugins/api/api_plugin_dev_named_registry.go b/plugins/api/api_plugin_dev_named_registry.go new file mode 100644 index 0000000..2ddb687 --- /dev/null +++ b/plugins/api/api_plugin_dev_named_registry.go @@ -0,0 +1,94 @@ +//go:build wasip1 + +package api + +import ( + "context" + "strings" + + "github.com/navidrome/navidrome/plugins/host/scheduler" +) + +var callbacks = make(namedCallbacks) + +// RegisterNamedSchedulerCallback registers a named scheduler callback. Named callbacks allow multiple callbacks to be registered +// within the same plugin, and for the schedules to be scoped to the named callback. If you only need a single callback, you can use +// the default (unnamed) callback registration function, RegisterSchedulerCallback. +// It returns a scheduler.SchedulerService that can be used to schedule jobs for the named callback. +// +// Notes: +// +// - You can't mix named and unnamed callbacks within the same plugin. +// - The name should be unique within the plugin, and it's recommended to use a short, descriptive name. +// - The name is case-sensitive. +func RegisterNamedSchedulerCallback(name string, cb SchedulerCallback) scheduler.SchedulerService { + callbacks[name] = cb + RegisterSchedulerCallback(&callbacks) + return &namedSchedulerService{name: name, svc: scheduler.NewSchedulerService()} +} + +const zwsp = string('\u200b') + +// namedCallbacks is a map of named scheduler callbacks. The key is the name of the callback, and the value is the callback itself. +type namedCallbacks map[string]SchedulerCallback + +func parseKey(key string) (string, string) { + parts := strings.SplitN(key, zwsp, 2) + if len(parts) != 2 { + return "", "" + } + return parts[0], parts[1] +} + +func (n *namedCallbacks) OnSchedulerCallback(ctx context.Context, req *SchedulerCallbackRequest) (*SchedulerCallbackResponse, error) { + name, scheduleId := parseKey(req.ScheduleId) + cb, exists := callbacks[name] + if !exists { + return nil, nil + } + req.ScheduleId = scheduleId + return cb.OnSchedulerCallback(ctx, req) +} + +// namedSchedulerService is a wrapper around the host scheduler service that prefixes the schedule IDs with the +// callback name. It is returned by RegisterNamedSchedulerCallback, and should be used by the plugin to schedule +// jobs for the named callback. +type namedSchedulerService struct { + name string + cb SchedulerCallback + svc scheduler.SchedulerService +} + +func (n *namedSchedulerService) makeKey(id string) string { + return n.name + zwsp + id +} + +func (n *namedSchedulerService) mapResponse(resp *scheduler.ScheduleResponse, err error) (*scheduler.ScheduleResponse, error) { + if err != nil { + return nil, err + } + _, resp.ScheduleId = parseKey(resp.ScheduleId) + return resp, nil +} + +func (n *namedSchedulerService) ScheduleOneTime(ctx context.Context, request *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) { + key := n.makeKey(request.ScheduleId) + request.ScheduleId = key + return n.mapResponse(n.svc.ScheduleOneTime(ctx, request)) +} + +func (n *namedSchedulerService) ScheduleRecurring(ctx context.Context, request *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) { + key := n.makeKey(request.ScheduleId) + request.ScheduleId = key + return n.mapResponse(n.svc.ScheduleRecurring(ctx, request)) +} + +func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *scheduler.CancelRequest) (*scheduler.CancelResponse, error) { + key := n.makeKey(request.ScheduleId) + request.ScheduleId = key + return n.svc.CancelSchedule(ctx, request) +} + +func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + return n.svc.TimeNow(ctx, request) +} diff --git a/plugins/api/api_vtproto.pb.go b/plugins/api/api_vtproto.pb.go new file mode 100644 index 0000000..11caa19 --- /dev/null +++ b/plugins/api/api_vtproto.pb.go @@ -0,0 +1,7315 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: api/api.proto + +package api + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *ArtistMBIDRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistMBIDRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistMBIDRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistMBIDResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistMBIDResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistMBIDResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistURLRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistURLRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistURLRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistURLResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistURLResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistURLResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistBiographyRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistBiographyRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistBiographyRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistBiographyResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistBiographyResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistBiographyResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Biography) > 0 { + i -= len(m.Biography) + copy(dAtA[i:], m.Biography) + i = encodeVarint(dAtA, i, uint64(len(m.Biography))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistSimilarRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistSimilarRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistSimilarRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Limit != 0 { + i = encodeVarint(dAtA, i, uint64(m.Limit)) + i-- + dAtA[i] = 0x20 + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Artist) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Artist) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *Artist) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistSimilarResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistSimilarResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistSimilarResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Artists) > 0 { + for iNdEx := len(m.Artists) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Artists[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *ArtistImageRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistImageRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistImageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ExternalImage) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ExternalImage) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ExternalImage) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Size != 0 { + i = encodeVarint(dAtA, i, uint64(m.Size)) + i-- + dAtA[i] = 0x10 + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistImageResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistImageResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistImageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Images) > 0 { + for iNdEx := len(m.Images) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Images[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *ArtistTopSongsRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistTopSongsRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistTopSongsRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Count != 0 { + i = encodeVarint(dAtA, i, uint64(m.Count)) + i-- + dAtA[i] = 0x20 + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.ArtistName) > 0 { + i -= len(m.ArtistName) + copy(dAtA[i:], m.ArtistName) + i = encodeVarint(dAtA, i, uint64(len(m.ArtistName))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *Song) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *Song) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *Song) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ArtistTopSongsResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ArtistTopSongsResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ArtistTopSongsResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Songs) > 0 { + for iNdEx := len(m.Songs) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Songs[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *AlbumInfoRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumInfoRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumInfoRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Artist) > 0 { + i -= len(m.Artist) + copy(dAtA[i:], m.Artist) + i = encodeVarint(dAtA, i, uint64(len(m.Artist))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumInfo) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumInfo) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumInfo) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0x22 + } + if len(m.Description) > 0 { + i -= len(m.Description) + copy(dAtA[i:], m.Description) + i = encodeVarint(dAtA, i, uint64(len(m.Description))) + i-- + dAtA[i] = 0x1a + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumInfoResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumInfoResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumInfoResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Info != nil { + size, err := m.Info.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumImagesRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumImagesRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumImagesRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x1a + } + if len(m.Artist) > 0 { + i -= len(m.Artist) + copy(dAtA[i:], m.Artist) + i = encodeVarint(dAtA, i, uint64(len(m.Artist))) + i-- + dAtA[i] = 0x12 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *AlbumImagesResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *AlbumImagesResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *AlbumImagesResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Images) > 0 { + for iNdEx := len(m.Images) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Images[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerIsAuthorizedRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerIsAuthorizedRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerIsAuthorizedRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Username) > 0 { + i -= len(m.Username) + copy(dAtA[i:], m.Username) + i = encodeVarint(dAtA, i, uint64(len(m.Username))) + i-- + dAtA[i] = 0x12 + } + if len(m.UserId) > 0 { + i -= len(m.UserId) + copy(dAtA[i:], m.UserId) + i = encodeVarint(dAtA, i, uint64(len(m.UserId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerIsAuthorizedResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerIsAuthorizedResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerIsAuthorizedResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if m.Authorized { + i-- + if m.Authorized { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *TrackInfo) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TrackInfo) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TrackInfo) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Position != 0 { + i = encodeVarint(dAtA, i, uint64(m.Position)) + i-- + dAtA[i] = 0x48 + } + if m.Length != 0 { + i = encodeVarint(dAtA, i, uint64(m.Length)) + i-- + dAtA[i] = 0x40 + } + if len(m.AlbumArtists) > 0 { + for iNdEx := len(m.AlbumArtists) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.AlbumArtists[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x3a + } + } + if len(m.Artists) > 0 { + for iNdEx := len(m.Artists) - 1; iNdEx >= 0; iNdEx-- { + size, err := m.Artists[iNdEx].MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x32 + } + } + if len(m.AlbumMbid) > 0 { + i -= len(m.AlbumMbid) + copy(dAtA[i:], m.AlbumMbid) + i = encodeVarint(dAtA, i, uint64(len(m.AlbumMbid))) + i-- + dAtA[i] = 0x2a + } + if len(m.Album) > 0 { + i -= len(m.Album) + copy(dAtA[i:], m.Album) + i = encodeVarint(dAtA, i, uint64(len(m.Album))) + i-- + dAtA[i] = 0x22 + } + if len(m.Name) > 0 { + i -= len(m.Name) + copy(dAtA[i:], m.Name) + i = encodeVarint(dAtA, i, uint64(len(m.Name))) + i-- + dAtA[i] = 0x1a + } + if len(m.Mbid) > 0 { + i -= len(m.Mbid) + copy(dAtA[i:], m.Mbid) + i = encodeVarint(dAtA, i, uint64(len(m.Mbid))) + i-- + dAtA[i] = 0x12 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerNowPlayingRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerNowPlayingRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerNowPlayingRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Timestamp != 0 { + i = encodeVarint(dAtA, i, uint64(m.Timestamp)) + i-- + dAtA[i] = 0x20 + } + if m.Track != nil { + size, err := m.Track.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } + if len(m.Username) > 0 { + i -= len(m.Username) + copy(dAtA[i:], m.Username) + i = encodeVarint(dAtA, i, uint64(len(m.Username))) + i-- + dAtA[i] = 0x12 + } + if len(m.UserId) > 0 { + i -= len(m.UserId) + copy(dAtA[i:], m.UserId) + i = encodeVarint(dAtA, i, uint64(len(m.UserId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerNowPlayingResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerNowPlayingResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerNowPlayingResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerScrobbleRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerScrobbleRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerScrobbleRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Timestamp != 0 { + i = encodeVarint(dAtA, i, uint64(m.Timestamp)) + i-- + dAtA[i] = 0x20 + } + if m.Track != nil { + size, err := m.Track.MarshalToSizedBufferVT(dAtA[:i]) + if err != nil { + return 0, err + } + i -= size + i = encodeVarint(dAtA, i, uint64(size)) + i-- + dAtA[i] = 0x1a + } + if len(m.Username) > 0 { + i -= len(m.Username) + copy(dAtA[i:], m.Username) + i = encodeVarint(dAtA, i, uint64(len(m.Username))) + i-- + dAtA[i] = 0x12 + } + if len(m.UserId) > 0 { + i -= len(m.UserId) + copy(dAtA[i:], m.UserId) + i = encodeVarint(dAtA, i, uint64(len(m.UserId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScrobblerScrobbleResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScrobblerScrobbleResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScrobblerScrobbleResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SchedulerCallbackRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SchedulerCallbackRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SchedulerCallbackRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.IsRecurring { + i-- + if m.IsRecurring { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x18 + } + if len(m.Payload) > 0 { + i -= len(m.Payload) + copy(dAtA[i:], m.Payload) + i = encodeVarint(dAtA, i, uint64(len(m.Payload))) + i-- + dAtA[i] = 0x12 + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SchedulerCallbackResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SchedulerCallbackResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SchedulerCallbackResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *InitRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *InitRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *InitRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Config) > 0 { + for k := range m.Config { + v := m.Config[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func (m *InitResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *InitResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *InitResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnTextMessageRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnTextMessageRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnTextMessageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Message) > 0 { + i -= len(m.Message) + copy(dAtA[i:], m.Message) + i = encodeVarint(dAtA, i, uint64(len(m.Message))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnTextMessageResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnTextMessageResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnTextMessageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *OnBinaryMessageRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnBinaryMessageRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnBinaryMessageRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Data) > 0 { + i -= len(m.Data) + copy(dAtA[i:], m.Data) + i = encodeVarint(dAtA, i, uint64(len(m.Data))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnBinaryMessageResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnBinaryMessageResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnBinaryMessageResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *OnErrorRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnErrorRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnErrorRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnErrorResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnErrorResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnErrorResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *OnCloseRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnCloseRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnCloseRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Reason) > 0 { + i -= len(m.Reason) + copy(dAtA[i:], m.Reason) + i = encodeVarint(dAtA, i, uint64(len(m.Reason))) + i-- + dAtA[i] = 0x1a + } + if m.Code != 0 { + i = encodeVarint(dAtA, i, uint64(m.Code)) + i-- + dAtA[i] = 0x10 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *OnCloseResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *OnCloseResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *OnCloseResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *ArtistMBIDRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistMBIDResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistURLRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistURLResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistBiographyRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistBiographyResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Biography) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistSimilarRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Limit != 0 { + n += 1 + sov(uint64(m.Limit)) + } + n += len(m.unknownFields) + return n +} + +func (m *Artist) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistSimilarResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Artists) > 0 { + for _, e := range m.Artists { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistImageRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ExternalImage) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Size != 0 { + n += 1 + sov(uint64(m.Size)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistImageResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Images) > 0 { + for _, e := range m.Images { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistTopSongsRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.ArtistName) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Count != 0 { + n += 1 + sov(uint64(m.Count)) + } + n += len(m.unknownFields) + return n +} + +func (m *Song) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ArtistTopSongsResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Songs) > 0 { + for _, e := range m.Songs { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumInfoRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Artist) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumInfo) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Description) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumInfoResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Info != nil { + l = m.Info.SizeVT() + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumImagesRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Artist) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *AlbumImagesResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Images) > 0 { + for _, e := range m.Images { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerIsAuthorizedRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.UserId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Username) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerIsAuthorizedResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Authorized { + n += 2 + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *TrackInfo) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Mbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Name) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Album) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.AlbumMbid) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Artists) > 0 { + for _, e := range m.Artists { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + if len(m.AlbumArtists) > 0 { + for _, e := range m.AlbumArtists { + l = e.SizeVT() + n += 1 + l + sov(uint64(l)) + } + } + if m.Length != 0 { + n += 1 + sov(uint64(m.Length)) + } + if m.Position != 0 { + n += 1 + sov(uint64(m.Position)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerNowPlayingRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.UserId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Username) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Track != nil { + l = m.Track.SizeVT() + n += 1 + l + sov(uint64(l)) + } + if m.Timestamp != 0 { + n += 1 + sov(uint64(m.Timestamp)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerNowPlayingResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerScrobbleRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.UserId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Username) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Track != nil { + l = m.Track.SizeVT() + n += 1 + l + sov(uint64(l)) + } + if m.Timestamp != 0 { + n += 1 + sov(uint64(m.Timestamp)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScrobblerScrobbleResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SchedulerCallbackRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Payload) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.IsRecurring { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func (m *SchedulerCallbackResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *InitRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Config) > 0 { + for k, v := range m.Config { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + n += len(m.unknownFields) + return n +} + +func (m *InitResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnTextMessageRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Message) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnTextMessageResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *OnBinaryMessageRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Data) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnBinaryMessageResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *OnErrorRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnErrorResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *OnCloseRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Code != 0 { + n += 1 + sov(uint64(m.Code)) + } + l = len(m.Reason) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *OnCloseResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *ArtistMBIDRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistMBIDRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistMBIDRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistMBIDResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistMBIDResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistMBIDResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistURLRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistURLRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistURLRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistURLResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistURLResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistURLResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistBiographyRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistBiographyRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistBiographyRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistBiographyResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistBiographyResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistBiographyResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Biography", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Biography = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistSimilarRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistSimilarRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistSimilarRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Limit", wireType) + } + m.Limit = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Limit |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Artist) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Artist: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Artist: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistSimilarResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistSimilarResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistSimilarResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artists", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artists = append(m.Artists, &Artist{}) + if err := m.Artists[len(m.Artists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistImageRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistImageRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistImageRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ExternalImage) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ExternalImage: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ExternalImage: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Size", wireType) + } + m.Size = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Size |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistImageResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistImageResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistImageResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Images", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Images = append(m.Images, &ExternalImage{}) + if err := m.Images[len(m.Images)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistTopSongsRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistTopSongsRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistTopSongsRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ArtistName", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ArtistName = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Count", wireType) + } + m.Count = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Count |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *Song) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: Song: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: Song: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ArtistTopSongsResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ArtistTopSongsResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ArtistTopSongsResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Songs", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Songs = append(m.Songs, &Song{}) + if err := m.Songs[len(m.Songs)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumInfoRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumInfoRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumInfoRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artist", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artist = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumInfo) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumInfo: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumInfo: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Description", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Description = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumInfoResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumInfoResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumInfoResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Info", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Info == nil { + m.Info = &AlbumInfo{} + } + if err := m.Info.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumImagesRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumImagesRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumImagesRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artist", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artist = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *AlbumImagesResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: AlbumImagesResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: AlbumImagesResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Images", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Images = append(m.Images, &ExternalImage{}) + if err := m.Images[len(m.Images)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerIsAuthorizedRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.UserId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Username = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerIsAuthorizedResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerIsAuthorizedResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Authorized", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Authorized = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TrackInfo) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TrackInfo: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TrackInfo: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Mbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Mbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Name", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Name = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Album", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Album = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 5: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AlbumMbid", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.AlbumMbid = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 6: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Artists", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Artists = append(m.Artists, &Artist{}) + if err := m.Artists[len(m.Artists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 7: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field AlbumArtists", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.AlbumArtists = append(m.AlbumArtists, &Artist{}) + if err := m.AlbumArtists[len(m.AlbumArtists)-1].UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 8: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Length", wireType) + } + m.Length = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Length |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 9: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Position", wireType) + } + m.Position = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Position |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerNowPlayingRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerNowPlayingRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerNowPlayingRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.UserId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Username = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Track", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Track == nil { + m.Track = &TrackInfo{} + } + if err := m.Track.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType) + } + m.Timestamp = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Timestamp |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerNowPlayingResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerNowPlayingResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerNowPlayingResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerScrobbleRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerScrobbleRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerScrobbleRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field UserId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.UserId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Username", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Username = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Track", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Track == nil { + m.Track = &TrackInfo{} + } + if err := m.Track.UnmarshalVT(dAtA[iNdEx:postIndex]); err != nil { + return err + } + iNdEx = postIndex + case 4: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Timestamp", wireType) + } + m.Timestamp = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Timestamp |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScrobblerScrobbleResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScrobblerScrobbleResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScrobblerScrobbleResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SchedulerCallbackRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SchedulerCallbackRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SchedulerCallbackRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...) + if m.Payload == nil { + m.Payload = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field IsRecurring", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.IsRecurring = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SchedulerCallbackResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SchedulerCallbackResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SchedulerCallbackResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *InitRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: InitRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: InitRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Config == nil { + m.Config = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Config[mapkey] = mapvalue + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *InitResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: InitResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: InitResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnTextMessageRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnTextMessageRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnTextMessageRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Message", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Message = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnTextMessageResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnTextMessageResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnTextMessageResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnBinaryMessageRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnBinaryMessageRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnBinaryMessageRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Data", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Data = append(m.Data[:0], dAtA[iNdEx:postIndex]...) + if m.Data == nil { + m.Data = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnBinaryMessageResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnBinaryMessageResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnBinaryMessageResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnErrorRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnErrorRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnErrorRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnErrorResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnErrorResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnErrorResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnCloseRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnCloseRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnCloseRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Code", wireType) + } + m.Code = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Code |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Reason", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Reason = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *OnCloseResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: OnCloseResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: OnCloseResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/api/errors.go b/plugins/api/errors.go new file mode 100644 index 0000000..796774b --- /dev/null +++ b/plugins/api/errors.go @@ -0,0 +1,12 @@ +package api + +import "errors" + +var ( + // ErrNotImplemented indicates that the plugin does not implement the requested method. + // No logic should be executed by the plugin. + ErrNotImplemented = errors.New("plugin:not_implemented") + + // ErrNotFound indicates that the requested resource was not found by the plugin. + ErrNotFound = errors.New("plugin:not_found") +) diff --git a/plugins/base_capability.go b/plugins/base_capability.go new file mode 100644 index 0000000..6572a25 --- /dev/null +++ b/plugins/base_capability.go @@ -0,0 +1,159 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/plugins/api" +) + +// newBaseCapability creates a new instance of baseCapability with the required parameters. +func newBaseCapability[S any, P any](wasmPath, id, capability string, m metrics.Metrics, loader P, loadFunc loaderFunc[S, P]) *baseCapability[S, P] { + return &baseCapability[S, P]{ + wasmPath: wasmPath, + id: id, + capability: capability, + loader: loader, + loadFunc: loadFunc, + metrics: m, + } +} + +// LoaderFunc is a generic function type that loads a plugin instance. +type loaderFunc[S any, P any] func(ctx context.Context, loader P, path string) (S, error) + +// baseCapability is a generic base implementation for WASM plugins. +// S is the capability interface type and P is the plugin loader type. +type baseCapability[S any, P any] struct { + wasmPath string + id string + capability string + loader P + loadFunc loaderFunc[S, P] + metrics metrics.Metrics +} + +func (w *baseCapability[S, P]) PluginID() string { + return w.id +} + +func (w *baseCapability[S, P]) serviceName() string { + return w.id + "_" + w.capability +} + +func (w *baseCapability[S, P]) getMetrics() metrics.Metrics { + return w.metrics +} + +// getInstance loads a new plugin instance and returns a cleanup function. +func (w *baseCapability[S, P]) getInstance(ctx context.Context, methodName string) (S, func(), error) { + start := time.Now() + // Add context metadata for tracing + ctx = log.NewContext(ctx, "capability", w.serviceName(), "method", methodName) + + inst, err := w.loadFunc(ctx, w.loader, w.wasmPath) + if err != nil { + var zero S + return zero, func() {}, fmt.Errorf("baseCapability: failed to load instance for %s: %w", w.serviceName(), err) + } + // Add context metadata for tracing + ctx = log.NewContext(ctx, "instanceID", getInstanceID(inst)) + log.Trace(ctx, "baseCapability: loaded instance", "elapsed", time.Since(start)) + return inst, func() { + log.Trace(ctx, "baseCapability: finished using instance", "elapsed", time.Since(start)) + if closer, ok := any(inst).(interface{ Close(context.Context) error }); ok { + _ = closer.Close(ctx) + } + }, nil +} + +type wasmPlugin[S any] interface { + PluginID() string + getInstance(ctx context.Context, methodName string) (S, func(), error) + getMetrics() metrics.Metrics +} + +func callMethod[S any, R any](ctx context.Context, wp WasmPlugin, methodName string, fn func(inst S) (R, error)) (R, error) { + // Add a unique call ID to the context for tracing + ctx = log.NewContext(ctx, "callID", id.NewRandom()) + var r R + + p, ok := wp.(wasmPlugin[S]) + if !ok { + log.Error(ctx, "callMethod: not a wasm plugin", "method", methodName, "pluginID", wp.PluginID()) + return r, fmt.Errorf("wasm plugin: not a wasm plugin: %s", wp.PluginID()) + } + + inst, done, err := p.getInstance(ctx, methodName) + if err != nil { + return r, err + } + start := time.Now() + defer done() + r, err = checkErr(fn(inst)) + elapsed := time.Since(start) + + if !errors.Is(err, api.ErrNotImplemented) { + id := p.PluginID() + isOk := err == nil + metrics := p.getMetrics() + if metrics != nil { + metrics.RecordPluginRequest(ctx, id, methodName, isOk, elapsed.Milliseconds()) + log.Trace(ctx, "callMethod: sending metrics", "plugin", id, "method", methodName, "ok", isOk, "elapsed", elapsed) + } + } + + return r, err +} + +// errorResponse is an interface that defines a method to retrieve an error message. +// It is automatically implemented (generated) by all plugin responses that have an Error field +type errorResponse interface { + GetError() string +} + +// checkErr returns an updated error if the response implements errorResponse and contains an error message. +// If the response is nil, it returns the original error. Otherwise, it wraps or creates an error as needed. +// It also maps error strings to their corresponding api.Err* constants. +func checkErr[T any](resp T, err error) (T, error) { + if any(resp) == nil { + return resp, mapAPIError(err) + } + respErr, ok := any(resp).(errorResponse) + if ok && respErr.GetError() != "" { + respErrMsg := respErr.GetError() + respErrErr := errors.New(respErrMsg) + mappedErr := mapAPIError(respErrErr) + // Check if the error was mapped to an API error (different from the temp error) + if errors.Is(mappedErr, api.ErrNotImplemented) || errors.Is(mappedErr, api.ErrNotFound) { + // Return the mapped API error instead of wrapping + return resp, mappedErr + } + // For non-API errors, use wrap the original error if it is not nil + return resp, errors.Join(respErrErr, err) + } + return resp, mapAPIError(err) +} + +// mapAPIError maps error strings to their corresponding api.Err* constants. +// This is needed as errors from plugins may not be of type api.Error, due to serialization/deserialization. +func mapAPIError(err error) error { + if err == nil { + return nil + } + + errStr := err.Error() + switch errStr { + case api.ErrNotImplemented.Error(): + return api.ErrNotImplemented + case api.ErrNotFound.Error(): + return api.ErrNotFound + default: + return err + } +} diff --git a/plugins/base_capability_test.go b/plugins/base_capability_test.go new file mode 100644 index 0000000..3bece8d --- /dev/null +++ b/plugins/base_capability_test.go @@ -0,0 +1,285 @@ +package plugins + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/plugins/api" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type nilInstance struct{} + +var _ = Describe("baseCapability", func() { + var ctx = context.Background() + + It("should load instance using loadFunc", func() { + called := false + plugin := &baseCapability[*nilInstance, any]{ + wasmPath: "", + id: "test", + capability: "test", + loadFunc: func(ctx context.Context, _ any, path string) (*nilInstance, error) { + called = true + return &nilInstance{}, nil + }, + } + inst, done, err := plugin.getInstance(ctx, "test") + defer done() + Expect(err).To(BeNil()) + Expect(inst).ToNot(BeNil()) + Expect(called).To(BeTrue()) + }) +}) + +var _ = Describe("checkErr", func() { + Context("when resp is nil", func() { + It("should return nil error when both resp and err are nil", func() { + var resp *testErrorResponse + + result, err := checkErr(resp, nil) + + Expect(result).To(BeNil()) + Expect(err).To(BeNil()) + }) + + It("should return original error unchanged for non-API errors", func() { + var resp *testErrorResponse + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(BeNil()) + Expect(err).To(Equal(originalErr)) + }) + + It("should return mapped API error for ErrNotImplemented", func() { + var resp *testErrorResponse + err := errors.New("plugin:not_implemented") + + result, mappedErr := checkErr(resp, err) + + Expect(result).To(BeNil()) + Expect(mappedErr).To(Equal(api.ErrNotImplemented)) + }) + + It("should return mapped API error for ErrNotFound", func() { + var resp *testErrorResponse + err := errors.New("plugin:not_found") + + result, mappedErr := checkErr(resp, err) + + Expect(result).To(BeNil()) + Expect(mappedErr).To(Equal(api.ErrNotFound)) + }) + }) + + Context("when resp is a typed nil that implements errorResponse", func() { + It("should not panic and return original error", func() { + var resp *testErrorResponse // typed nil + originalErr := errors.New("original error") + + // This should not panic + result, err := checkErr(resp, originalErr) + + Expect(result).To(BeNil()) + Expect(err).To(Equal(originalErr)) + }) + + It("should handle typed nil with nil error gracefully", func() { + var resp *testErrorResponse // typed nil + + // This should not panic + result, err := checkErr(resp, nil) + + Expect(result).To(BeNil()) + Expect(err).To(BeNil()) + }) + }) + + Context("when resp implements errorResponse with non-empty error", func() { + It("should create new error when original error is nil", func() { + resp := &testErrorResponse{errorMsg: "plugin error"} + + result, err := checkErr(resp, nil) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError("plugin error")) + }) + + It("should wrap original error when both exist", func() { + resp := &testErrorResponse{errorMsg: "plugin error"} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(HaveOccurred()) + // Check that both error messages are present in the joined error + errStr := err.Error() + Expect(errStr).To(ContainSubstring("plugin error")) + Expect(errStr).To(ContainSubstring("original error")) + }) + + It("should return mapped API error for ErrNotImplemented when no original error", func() { + resp := &testErrorResponse{errorMsg: "plugin:not_implemented"} + + result, err := checkErr(resp, nil) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(api.ErrNotImplemented)) + }) + + It("should return mapped API error for ErrNotFound when no original error", func() { + resp := &testErrorResponse{errorMsg: "plugin:not_found"} + + result, err := checkErr(resp, nil) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(api.ErrNotFound)) + }) + + It("should return mapped API error for ErrNotImplemented even with original error", func() { + resp := &testErrorResponse{errorMsg: "plugin:not_implemented"} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(api.ErrNotImplemented)) + }) + + It("should return mapped API error for ErrNotFound even with original error", func() { + resp := &testErrorResponse{errorMsg: "plugin:not_found"} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(api.ErrNotFound)) + }) + }) + + Context("when resp implements errorResponse with empty error", func() { + It("should return original error unchanged", func() { + resp := &testErrorResponse{errorMsg: ""} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(originalErr)) + }) + + It("should return nil error when both are empty/nil", func() { + resp := &testErrorResponse{errorMsg: ""} + + result, err := checkErr(resp, nil) + + Expect(result).To(Equal(resp)) + Expect(err).To(BeNil()) + }) + + It("should map original API error when response error is empty", func() { + resp := &testErrorResponse{errorMsg: ""} + originalErr := errors.New("plugin:not_implemented") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(api.ErrNotImplemented)) + }) + }) + + Context("when resp does not implement errorResponse", func() { + It("should return original error unchanged", func() { + resp := &testNonErrorResponse{data: "some data"} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(Equal(originalErr)) + }) + + It("should return nil error when original error is nil", func() { + resp := &testNonErrorResponse{data: "some data"} + + result, err := checkErr(resp, nil) + + Expect(result).To(Equal(resp)) + Expect(err).To(BeNil()) + }) + + It("should map original API error when response doesn't implement errorResponse", func() { + resp := &testNonErrorResponse{data: "some data"} + originalErr := errors.New("plugin:not_found") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(api.ErrNotFound)) + }) + }) + + Context("when resp is a value type (not pointer)", func() { + It("should handle value types that implement errorResponse", func() { + resp := testValueErrorResponse{errorMsg: "value error"} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(HaveOccurred()) + // Check that both error messages are present in the joined error + errStr := err.Error() + Expect(errStr).To(ContainSubstring("value error")) + Expect(errStr).To(ContainSubstring("original error")) + }) + + It("should handle value types with empty error", func() { + resp := testValueErrorResponse{errorMsg: ""} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(originalErr)) + }) + + It("should handle value types with API error", func() { + resp := testValueErrorResponse{errorMsg: "plugin:not_implemented"} + originalErr := errors.New("original error") + + result, err := checkErr(resp, originalErr) + + Expect(result).To(Equal(resp)) + Expect(err).To(MatchError(api.ErrNotImplemented)) + }) + }) +}) + +// Test helper types +type testErrorResponse struct { + errorMsg string +} + +func (t *testErrorResponse) GetError() string { + if t == nil { + return "" // This is what would typically happen with a typed nil + } + return t.errorMsg +} + +type testNonErrorResponse struct { + data string +} + +type testValueErrorResponse struct { + errorMsg string +} + +func (t testValueErrorResponse) GetError() string { + return t.errorMsg +} diff --git a/plugins/discovery.go b/plugins/discovery.go new file mode 100644 index 0000000..4125da3 --- /dev/null +++ b/plugins/discovery.go @@ -0,0 +1,145 @@ +package plugins + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// PluginDiscoveryEntry represents the result of plugin discovery +type PluginDiscoveryEntry struct { + ID string // Plugin ID (directory name) + Path string // Resolved plugin directory path + WasmPath string // Path to the WASM file + Manifest *schema.PluginManifest // Loaded manifest (nil if failed) + IsSymlink bool // Whether the plugin is a development symlink + Error error // Error encountered during discovery +} + +// DiscoverPlugins scans the plugins directory and returns information about all discoverable plugins +// This shared function eliminates duplication between ScanPlugins and plugin list commands +func DiscoverPlugins(pluginsDir string) []PluginDiscoveryEntry { + var discoveries []PluginDiscoveryEntry + + entries, err := os.ReadDir(pluginsDir) + if err != nil { + // Return a single entry with the error + return []PluginDiscoveryEntry{{ + Error: fmt.Errorf("failed to read plugins directory %s: %w", pluginsDir, err), + }} + } + + for _, entry := range entries { + name := entry.Name() + pluginPath := filepath.Join(pluginsDir, name) + + // Skip hidden files + if name[0] == '.' { + continue + } + + // Check if it's a directory or symlink + info, err := os.Lstat(pluginPath) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Error: fmt.Errorf("failed to stat entry %s: %w", pluginPath, err), + }) + continue + } + + isSymlink := info.Mode()&os.ModeSymlink != 0 + isDir := info.IsDir() + + // Skip if not a directory or symlink + if !isDir && !isSymlink { + continue + } + + // Resolve symlinks + pluginDir := pluginPath + if isSymlink { + targetDir, err := os.Readlink(pluginPath) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + IsSymlink: true, + Error: fmt.Errorf("failed to resolve symlink %s: %w", pluginPath, err), + }) + continue + } + + // If target is a relative path, make it absolute + if !filepath.IsAbs(targetDir) { + targetDir = filepath.Join(filepath.Dir(pluginPath), targetDir) + } + + // Verify that the target is a directory + targetInfo, err := os.Stat(targetDir) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + IsSymlink: true, + Error: fmt.Errorf("failed to stat symlink target %s: %w", targetDir, err), + }) + continue + } + + if !targetInfo.IsDir() { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + IsSymlink: true, + Error: fmt.Errorf("symlink target is not a directory: %s", targetDir), + }) + continue + } + + pluginDir = targetDir + } + + // Check for WASM file + wasmPath := filepath.Join(pluginDir, "plugin.wasm") + if _, err := os.Stat(wasmPath); err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + Error: fmt.Errorf("no plugin.wasm found: %w", err), + }) + continue + } + + // Load manifest + manifest, err := LoadManifest(pluginDir) + if err != nil { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + Error: fmt.Errorf("failed to load manifest: %w", err), + }) + continue + } + + // Check for capabilities + if len(manifest.Capabilities) == 0 { + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + Error: fmt.Errorf("no capabilities found in manifest"), + }) + continue + } + + // Success! + discoveries = append(discoveries, PluginDiscoveryEntry{ + ID: name, + Path: pluginDir, + WasmPath: wasmPath, + Manifest: manifest, + IsSymlink: isSymlink, + }) + } + + return discoveries +} diff --git a/plugins/discovery_test.go b/plugins/discovery_test.go new file mode 100644 index 0000000..a5fd345 --- /dev/null +++ b/plugins/discovery_test.go @@ -0,0 +1,402 @@ +package plugins + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("DiscoverPlugins", func() { + var tempPluginsDir string + + // Helper to create a valid plugin for discovery testing + createValidPlugin := func(name, manifestName, author, version string, capabilities []string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "` + manifestName + `", + "version": "` + version + `", + "capabilities": [` + for i, cap := range capabilities { + if i > 0 { + manifest += `, ` + } + manifest += `"` + cap + `"` + } + manifest += `], + "author": "` + author + `", + "description": "Test Plugin", + "website": "https://test.navidrome.org/` + manifestName + `", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + createManifestOnlyPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + manifest := `{ + "name": "manifest-only", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/manifest-only", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + createWasmOnlyPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + } + + createInvalidManifestPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + invalidManifest := `{ "invalid": "json" }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidManifest), 0600)).To(Succeed()) + } + + createEmptyCapabilitiesPlugin := func(name string) { + pluginDir := filepath.Join(tempPluginsDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "empty-capabilities", + "version": "1.0.0", + "capabilities": [], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/empty-capabilities", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + BeforeEach(func() { + tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-discovery-test-*") + DeferCleanup(func() { + _ = os.RemoveAll(tempPluginsDir) + }) + }) + + Context("Valid plugins", func() { + It("should discover valid plugins with all required files", func() { + createValidPlugin("test-plugin", "Test Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + createValidPlugin("another-plugin", "Another Plugin", "Another Author", "2.0.0", []string{"Scrobbler"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(2)) + + // Find each plugin by ID + var testPlugin, anotherPlugin *PluginDiscoveryEntry + for i := range discoveries { + switch discoveries[i].ID { + case "test-plugin": + testPlugin = &discoveries[i] + case "another-plugin": + anotherPlugin = &discoveries[i] + } + } + + Expect(testPlugin).NotTo(BeNil()) + Expect(testPlugin.Error).To(BeNil()) + Expect(testPlugin.Manifest.Name).To(Equal("Test Plugin")) + Expect(string(testPlugin.Manifest.Capabilities[0])).To(Equal("MetadataAgent")) + + Expect(anotherPlugin).NotTo(BeNil()) + Expect(anotherPlugin.Error).To(BeNil()) + Expect(anotherPlugin.Manifest.Name).To(Equal("Another Plugin")) + Expect(string(anotherPlugin.Manifest.Capabilities[0])).To(Equal("Scrobbler")) + }) + + It("should handle plugins with same manifest name in different directories", func() { + createValidPlugin("lastfm-official", "lastfm", "Official Author", "1.0.0", []string{"MetadataAgent"}) + createValidPlugin("lastfm-custom", "lastfm", "Custom Author", "2.0.0", []string{"MetadataAgent"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(2)) + + // Find each plugin by ID + var officialPlugin, customPlugin *PluginDiscoveryEntry + for i := range discoveries { + switch discoveries[i].ID { + case "lastfm-official": + officialPlugin = &discoveries[i] + case "lastfm-custom": + customPlugin = &discoveries[i] + } + } + + Expect(officialPlugin).NotTo(BeNil()) + Expect(officialPlugin.Error).To(BeNil()) + Expect(officialPlugin.Manifest.Name).To(Equal("lastfm")) + Expect(officialPlugin.Manifest.Author).To(Equal("Official Author")) + + Expect(customPlugin).NotTo(BeNil()) + Expect(customPlugin.Error).To(BeNil()) + Expect(customPlugin.Manifest.Name).To(Equal("lastfm")) + Expect(customPlugin.Manifest.Author).To(Equal("Custom Author")) + }) + }) + + Context("Missing files", func() { + It("should report error for plugins missing WASM files", func() { + createManifestOnlyPlugin("manifest-only") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("manifest-only")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("no plugin.wasm found")) + }) + + It("should skip directories missing manifest files", func() { + createWasmOnlyPlugin("wasm-only") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("wasm-only")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest")) + }) + }) + + Context("Invalid content", func() { + It("should report error for invalid manifest JSON", func() { + createInvalidManifestPlugin("invalid-manifest") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("invalid-manifest")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to load manifest")) + }) + + It("should report error for plugins with empty capabilities", func() { + createEmptyCapabilitiesPlugin("empty-capabilities") + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("empty-capabilities")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("field capabilities length: must be >= 1")) + }) + }) + + Context("Symlinks", func() { + It("should discover symlinked plugins correctly", func() { + // Create a real plugin directory outside tempPluginsDir + realPluginDir, err := os.MkdirTemp("", "navidrome-real-plugin-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(func() { + _ = os.RemoveAll(realPluginDir) + }) + + // Create plugin files in the real directory + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "symlinked-plugin", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/symlinked-plugin", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + + // Create symlink + symlinkPath := filepath.Join(tempPluginsDir, "symlinked-plugin") + Expect(os.Symlink(realPluginDir, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("symlinked-plugin")) + Expect(discoveries[0].Error).To(BeNil()) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + Expect(discoveries[0].Path).To(Equal(realPluginDir)) + Expect(discoveries[0].Manifest.Name).To(Equal("symlinked-plugin")) + }) + + It("should handle relative symlinks", func() { + // Create a real plugin directory in the same parent as tempPluginsDir + parentDir := filepath.Dir(tempPluginsDir) + realPluginDir := filepath.Join(parentDir, "real-plugin-dir") + Expect(os.MkdirAll(realPluginDir, 0755)).To(Succeed()) + DeferCleanup(func() { + _ = os.RemoveAll(realPluginDir) + }) + + // Create plugin files in the real directory + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(realPluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "relative-symlinked-plugin", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/relative-symlinked-plugin", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(realPluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + + // Create relative symlink + symlinkPath := filepath.Join(tempPluginsDir, "relative-symlinked-plugin") + relativeTarget := "../real-plugin-dir" + Expect(os.Symlink(relativeTarget, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("relative-symlinked-plugin")) + Expect(discoveries[0].Error).To(BeNil()) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + Expect(discoveries[0].Path).To(Equal(realPluginDir)) + Expect(discoveries[0].Manifest.Name).To(Equal("relative-symlinked-plugin")) + }) + + It("should report error for broken symlinks", func() { + symlinkPath := filepath.Join(tempPluginsDir, "broken-symlink") + nonExistentTarget := "/non/existent/path" + Expect(os.Symlink(nonExistentTarget, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("broken-symlink")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to stat symlink target")) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + }) + + It("should report error for symlinks pointing to files", func() { + // Create a regular file + regularFile := filepath.Join(tempPluginsDir, "regular-file.txt") + Expect(os.WriteFile(regularFile, []byte("content"), 0600)).To(Succeed()) + + // Create symlink pointing to the file + symlinkPath := filepath.Join(tempPluginsDir, "symlink-to-file") + Expect(os.Symlink(regularFile, symlinkPath)).To(Succeed()) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("symlink-to-file")) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("symlink target is not a directory")) + Expect(discoveries[0].IsSymlink).To(BeTrue()) + }) + }) + + Context("Directory filtering", func() { + It("should ignore hidden directories", func() { + createValidPlugin(".hidden-plugin", "Hidden Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + createValidPlugin("visible-plugin", "Visible Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("visible-plugin")) + }) + + It("should ignore regular files", func() { + // Create a regular file + Expect(os.WriteFile(filepath.Join(tempPluginsDir, "regular-file.txt"), []byte("content"), 0600)).To(Succeed()) + createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].ID).To(Equal("valid-plugin")) + }) + + It("should handle mixed valid and invalid plugins", func() { + createValidPlugin("valid-plugin", "Valid Plugin", "Test Author", "1.0.0", []string{"MetadataAgent"}) + createManifestOnlyPlugin("manifest-only") + createInvalidManifestPlugin("invalid-manifest") + createValidPlugin("another-valid", "Another Valid", "Test Author", "1.0.0", []string{"Scrobbler"}) + + discoveries := DiscoverPlugins(tempPluginsDir) + + Expect(discoveries).To(HaveLen(4)) + + var validCount int + var errorCount int + for _, discovery := range discoveries { + if discovery.Error == nil { + validCount++ + } else { + errorCount++ + } + } + + Expect(validCount).To(Equal(2)) + Expect(errorCount).To(Equal(2)) + }) + }) + + Context("Error handling", func() { + It("should handle non-existent plugins directory", func() { + nonExistentDir := "/non/existent/plugins/dir" + + discoveries := DiscoverPlugins(nonExistentDir) + + Expect(discoveries).To(HaveLen(1)) + Expect(discoveries[0].Error).To(HaveOccurred()) + Expect(discoveries[0].Error.Error()).To(ContainSubstring("failed to read plugins directory")) + }) + }) +}) diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile new file mode 100644 index 0000000..e2acc2f --- /dev/null +++ b/plugins/examples/Makefile @@ -0,0 +1,27 @@ +all: wikimedia coverartarchive crypto-ticker discord-rich-presence subsonicapi-demo + +wikimedia: wikimedia/plugin.wasm +coverartarchive: coverartarchive/plugin.wasm +crypto-ticker: crypto-ticker/plugin.wasm +discord-rich-presence: discord-rich-presence/plugin.wasm +subsonicapi-demo: subsonicapi-demo/plugin.wasm + +wikimedia/plugin.wasm: wikimedia/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./wikimedia + +coverartarchive/plugin.wasm: coverartarchive/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./coverartarchive + +crypto-ticker/plugin.wasm: crypto-ticker/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./crypto-ticker + +DISCORD_RP_FILES=$(shell find discord-rich-presence -type f -name "*.go") +discord-rich-presence/plugin.wasm: $(DISCORD_RP_FILES) + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./discord-rich-presence/... + +subsonicapi-demo/plugin.wasm: subsonicapi-demo/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./subsonicapi-demo + +clean: + rm -f wikimedia/plugin.wasm coverartarchive/plugin.wasm crypto-ticker/plugin.wasm \ + discord-rich-presence/plugin.wasm subsonicapi-demo/plugin.wasm \ No newline at end of file diff --git a/plugins/examples/README.md b/plugins/examples/README.md new file mode 100644 index 0000000..61d6b2e --- /dev/null +++ b/plugins/examples/README.md @@ -0,0 +1,31 @@ +# Plugin Examples + +This directory contains example plugins for Navidrome, intended for demonstration and reference purposes. These plugins are not used in automated tests. + +## Contents + +- `wikimedia/`: Retrieves artist information from Wikidata. +- `coverartarchive/`: Fetches album cover images from the Cover Art Archive. +- `crypto-ticker/`: Uses websockets to log real-time cryptocurrency prices. +- `discord-rich-presence/`: Integrates with Discord Rich Presence to display currently playing tracks on Discord profiles. +- `subsonicapi-demo/`: Demonstrates interaction with Navidrome's Subsonic API from a plugin. + +## Building + +To build all example plugins, run: + +``` +make +``` + +Or to build a specific plugin: + +``` +make wikimedia +make coverartarchive +make crypto-ticker +make discord-rich-presence +make subsonicapi-demo +``` + +This will produce the corresponding `plugin.wasm` files in each plugin's directory. diff --git a/plugins/examples/coverartarchive/README.md b/plugins/examples/coverartarchive/README.md new file mode 100644 index 0000000..e886f68 --- /dev/null +++ b/plugins/examples/coverartarchive/README.md @@ -0,0 +1,34 @@ +# Cover Art Archive AlbumMetadataService Plugin + +This plugin provides album cover images for Navidrome by querying the [Cover Art Archive](https://coverartarchive.org/) API using the MusicBrainz Release Group MBID. + +## Features + +- Implements only the `GetAlbumImages` method of the AlbumMetadataService plugin interface. +- Returns front cover images for a given release-group MBID. +- Returns `not found` if no MBID is provided or no images are found. + +## Requirements + +- Go 1.24 or newer (with WASI support) +- The Navidrome repository (with generated plugin API code in `plugins/api`) + +## How to Compile + +To build the WASM plugin, run the following command from the project root: + +```sh +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugins/testdata/coverartarchive/plugin.wasm ./plugins/testdata/coverartarchive +``` + +This will produce `plugin.wasm` in this directory. + +## Usage + +- The plugin can be loaded by Navidrome for integration and end-to-end tests of the plugin system. +- It is intended for testing and development purposes only. + +## API Reference + +- [Cover Art Archive API](https://musicbrainz.org/doc/Cover_Art_Archive/API) +- This plugin uses the endpoint: `https://coverartarchive.org/release-group/{mbid}` diff --git a/plugins/examples/coverartarchive/manifest.json b/plugins/examples/coverartarchive/manifest.json new file mode 100644 index 0000000..4049fc3 --- /dev/null +++ b/plugins/examples/coverartarchive/manifest.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "coverartarchive", + "author": "Navidrome", + "version": "1.0.0", + "description": "Album cover art from the Cover Art Archive", + "website": "https://coverartarchive.org", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch album cover art from the Cover Art Archive API", + "allowedUrls": { + "https://coverartarchive.org": ["GET"], + "https://*.archive.org": ["GET"] + }, + "allowLocalNetwork": false + } + } +} diff --git a/plugins/examples/coverartarchive/plugin.go b/plugins/examples/coverartarchive/plugin.go new file mode 100644 index 0000000..ee612c3 --- /dev/null +++ b/plugins/examples/coverartarchive/plugin.go @@ -0,0 +1,151 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/http" +) + +type CoverArtArchiveAgent struct{} + +var ErrNotFound = api.ErrNotFound + +type caaImage struct { + Image string `json:"image"` + Front bool `json:"front"` + Types []string `json:"types"` + Thumbnails map[string]string `json:"thumbnails"` +} + +var client = http.NewHttpService() + +func (CoverArtArchiveAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + if req.Mbid == "" { + return nil, ErrNotFound + } + + url := "https://coverartarchive.org/release/" + req.Mbid + resp, err := client.Get(ctx, &http.HttpRequest{Url: url, TimeoutMs: 5000}) + if err != nil || resp.Status != 200 { + log.Printf("[CAA] Error getting album images from CoverArtArchive (status: %d): %v", resp.Status, err) + return nil, ErrNotFound + } + + images, err := extractFrontImages(resp.Body) + if err != nil || len(images) == 0 { + return nil, ErrNotFound + } + return &api.AlbumImagesResponse{Images: images}, nil +} + +func extractFrontImages(body []byte) ([]*api.ExternalImage, error) { + var data struct { + Images []caaImage `json:"images"` + } + if err := json.Unmarshal(body, &data); err != nil { + return nil, err + } + img := findFrontImage(data.Images) + if img == nil { + return nil, ErrNotFound + } + return buildImageList(img), nil +} + +func findFrontImage(images []caaImage) *caaImage { + for i, img := range images { + if img.Front { + return &images[i] + } + } + for i, img := range images { + for _, t := range img.Types { + if t == "Front" { + return &images[i] + } + } + } + if len(images) > 0 { + return &images[0] + } + return nil +} + +func buildImageList(img *caaImage) []*api.ExternalImage { + var images []*api.ExternalImage + // First, try numeric sizes only + for sizeStr, url := range img.Thumbnails { + if url == "" { + continue + } + size := 0 + if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil { + images = append(images, &api.ExternalImage{Url: url, Size: int32(size)}) + } + } + // If no numeric sizes, fallback to large/small + if len(images) == 0 { + for sizeStr, url := range img.Thumbnails { + if url == "" { + continue + } + var size int + switch sizeStr { + case "large": + size = 500 + case "small": + size = 250 + default: + continue + } + images = append(images, &api.ExternalImage{Url: url, Size: int32(size)}) + } + } + if len(images) == 0 && img.Image != "" { + images = append(images, &api.ExternalImage{Url: img.Image, Size: 0}) + } + return images +} + +func (CoverArtArchiveAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + return nil, api.ErrNotImplemented +} +func (CoverArtArchiveAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return nil, api.ErrNotImplemented +} + +func (CoverArtArchiveAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, api.ErrNotImplemented +} + +func main() {} + +func init() { + // Configure logging: No timestamps, no source file/line + log.SetFlags(0) + log.SetPrefix("[CAA] ") + + api.RegisterMetadataAgent(CoverArtArchiveAgent{}) +} diff --git a/plugins/examples/crypto-ticker/README.md b/plugins/examples/crypto-ticker/README.md new file mode 100644 index 0000000..ca6d2c4 --- /dev/null +++ b/plugins/examples/crypto-ticker/README.md @@ -0,0 +1,53 @@ +# 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 WebSocketCallback and LifecycleManagement interfaces +- Automatically reconnects on connection loss +- Displays price, best bid, best ask, and 24-hour percentage change + +## Configuration + +In your `navidrome.toml` file, add: + +```toml +[PluginConfig.crypto-ticker] +tickers = "BTC,ETH,SOL,MATIC" +``` + +- `tickers` is a comma-separated list of cryptocurrency symbols +- The plugin will append `-USD` to any symbol without a trading pair specified + +## How it Works + +- The plugin connects to Coinbase's WebSocket API upon initialization +- It subscribes to ticker updates for the configured cryptocurrencies +- Incoming ticker data is processed and logged +- On connection loss, it automatically attempts to reconnect (TODO) + +## Building + +To build the plugin to WASM: + +``` +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go +``` + +## Installation + +Copy the resulting `plugin.wasm` and create a `manifest.json` file in your Navidrome plugins folder under a `crypto-ticker` directory. + +## Example Output + +``` +CRYPTO TICKER: BTC-USD Price: 65432.50 Best Bid: 65431.25 Best Ask: 65433.75 24h Change: 2.75% +CRYPTO TICKER: ETH-USD Price: 3456.78 Best Bid: 3455.90 Best Ask: 3457.80 24h Change: 1.25% +``` + +--- + +For more details, see the source code in `plugin.go`. diff --git a/plugins/examples/crypto-ticker/manifest.json b/plugins/examples/crypto-ticker/manifest.json new file mode 100644 index 0000000..4827316 --- /dev/null +++ b/plugins/examples/crypto-ticker/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "crypto-ticker", + "author": "Navidrome Plugin", + "version": "1.0.0", + "description": "A plugin that tracks crypto currency prices using Coinbase WebSocket API", + "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/crypto-ticker", + "capabilities": [ + "WebSocketCallback", + "LifecycleManagement", + "SchedulerCallback" + ], + "permissions": { + "config": { + "reason": "To read API configuration and WebSocket endpoint settings" + }, + "scheduler": { + "reason": "To schedule periodic reconnection attempts and status updates" + }, + "websocket": { + "reason": "To connect to Coinbase WebSocket API for real-time cryptocurrency prices", + "allowedUrls": ["wss://ws-feed.exchange.coinbase.com"], + "allowLocalNetwork": false + } + } +} diff --git a/plugins/examples/crypto-ticker/plugin.go b/plugins/examples/crypto-ticker/plugin.go new file mode 100644 index 0000000..3fced6d --- /dev/null +++ b/plugins/examples/crypto-ticker/plugin.go @@ -0,0 +1,304 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/config" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/websocket" +) + +const ( + // Coinbase WebSocket API endpoint + coinbaseWSEndpoint = "wss://ws-feed.exchange.coinbase.com" + + // Connection ID for our WebSocket connection + connectionID = "crypto-ticker-connection" + + // ID for the reconnection schedule + reconnectScheduleID = "crypto-ticker-reconnect" +) + +var ( + // Store ticker symbols from the configuration + tickers []string +) + +// WebSocketService instance used to manage WebSocket connections and communication. +var wsService = websocket.NewWebSocketService() + +// ConfigService instance for accessing plugin configuration. +var configService = config.NewConfigService() + +// SchedulerService instance for scheduling tasks. +var schedService = scheduler.NewSchedulerService() + +// CryptoTickerPlugin implements WebSocketCallback, LifecycleManagement, and SchedulerCallback interfaces +type CryptoTickerPlugin struct{} + +// Coinbase subscription message structure +type CoinbaseSubscription struct { + Type string `json:"type"` + ProductIDs []string `json:"product_ids"` + Channels []string `json:"channels"` +} + +// Coinbase ticker 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"` + Volume30d string `json:"volume_30d"` + BestBid string `json:"best_bid"` + BestAsk string `json:"best_ask"` + Side string `json:"side"` + Time string `json:"time"` + TradeID int `json:"trade_id"` + LastSize string `json:"last_size"` +} + +// OnInit is called when the plugin is loaded +func (CryptoTickerPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("Crypto Ticker Plugin initializing...") + + // Check if ticker configuration exists + tickerConfig, ok := req.Config["tickers"] + if !ok { + return &api.InitResponse{Error: "Missing 'tickers' configuration"}, nil + } + + // Parse ticker symbols + tickers := parseTickerSymbols(tickerConfig) + log.Printf("Configured tickers: %v", tickers) + + // Connect to WebSocket and subscribe to tickers + err := connectAndSubscribe(ctx, tickers) + if err != nil { + return &api.InitResponse{Error: err.Error()}, nil + } + + return &api.InitResponse{}, nil +} + +// Helper function to parse ticker symbols from a comma-separated string +func parseTickerSymbols(tickerConfig string) []string { + tickers := strings.Split(tickerConfig, ",") + for i, ticker := range tickers { + tickers[i] = strings.TrimSpace(ticker) + + // Add -USD suffix if not present + if !strings.Contains(tickers[i], "-") { + tickers[i] = tickers[i] + "-USD" + } + } + return tickers +} + +// Helper function to connect to WebSocket and subscribe to tickers +func connectAndSubscribe(ctx context.Context, tickers []string) error { + // Connect to the WebSocket API + _, err := wsService.Connect(ctx, &websocket.ConnectRequest{ + Url: coinbaseWSEndpoint, + ConnectionId: connectionID, + }) + + if err != nil { + log.Printf("Failed to connect to Coinbase WebSocket API: %v", err) + return fmt.Errorf("WebSocket connection error: %v", err) + } + + log.Printf("Connected to Coinbase WebSocket API") + + // Subscribe to ticker channel for the configured symbols + subscription := CoinbaseSubscription{ + Type: "subscribe", + ProductIDs: tickers, + Channels: []string{"ticker"}, + } + + subscriptionJSON, err := json.Marshal(subscription) + if err != nil { + log.Printf("Failed to marshal subscription message: %v", err) + return fmt.Errorf("JSON marshal error: %v", err) + } + + // Send subscription message + _, err = wsService.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: connectionID, + Message: string(subscriptionJSON), + }) + + if err != nil { + log.Printf("Failed to send subscription message: %v", err) + return fmt.Errorf("WebSocket send error: %v", err) + } + + log.Printf("Subscription message sent to Coinbase WebSocket API") + return nil +} + +// OnTextMessage is called when a text message is received from the WebSocket +func (CryptoTickerPlugin) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) { + // Only process messages from our connection + if req.ConnectionId != connectionID { + log.Printf("Received message from unexpected connection: %s", req.ConnectionId) + return &api.OnTextMessageResponse{}, nil + } + + // Try to parse as a ticker message + var ticker CoinbaseTicker + err := json.Unmarshal([]byte(req.Message), &ticker) + if err != nil { + log.Printf("Failed to parse ticker message: %v", err) + return &api.OnTextMessageResponse{}, nil + } + + // If the message is not a ticker or has an error, just log it + if ticker.Type != "ticker" { + // This could be subscription confirmation or other messages + log.Printf("Received non-ticker message: %s", req.Message) + return &api.OnTextMessageResponse{}, nil + } + + // Format and print ticker information + log.Printf("CRYPTO TICKER: %s Price: %s Best Bid: %s Best Ask: %s 24h Change: %s%%\n", + ticker.ProductID, + ticker.Price, + ticker.BestBid, + ticker.BestAsk, + calculatePercentChange(ticker.Open24h, ticker.Price), + ) + + return &api.OnTextMessageResponse{}, nil +} + +// OnBinaryMessage is called when a binary message is received +func (CryptoTickerPlugin) OnBinaryMessage(ctx context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) { + // Not expected from Coinbase WebSocket API + return &api.OnBinaryMessageResponse{}, nil +} + +// OnError is called when an error occurs on the WebSocket connection +func (CryptoTickerPlugin) OnError(ctx context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) { + log.Printf("WebSocket error: %s", req.Error) + return &api.OnErrorResponse{}, nil +} + +// OnClose is called when the WebSocket connection is closed +func (CryptoTickerPlugin) OnClose(ctx context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) { + log.Printf("WebSocket connection closed with code %d: %s", req.Code, req.Reason) + + // Try to reconnect if this is our connection + if req.ConnectionId == connectionID { + log.Printf("Scheduling reconnection attempts every 2 seconds...") + + // Create a recurring schedule to attempt reconnection every 2 seconds + resp, err := schedService.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + // Run every 2 seconds using cron expression + CronExpression: "*/2 * * * * *", + ScheduleId: reconnectScheduleID, + }) + + if err != nil { + log.Printf("Failed to schedule reconnection attempts: %v", err) + } else { + log.Printf("Reconnection schedule created with ID: %s", resp.ScheduleId) + } + } + + return &api.OnCloseResponse{}, nil +} + +// OnSchedulerCallback is called when a scheduled event triggers +func (CryptoTickerPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + // Only handle our reconnection schedule + if req.ScheduleId != reconnectScheduleID { + log.Printf("Received callback for unknown schedule: %s", req.ScheduleId) + return &api.SchedulerCallbackResponse{}, nil + } + + log.Printf("Attempting to reconnect to Coinbase WebSocket API...") + + // Get the current ticker configuration + configResp, err := configService.GetPluginConfig(ctx, &config.GetPluginConfigRequest{}) + if err != nil { + log.Printf("Failed to get plugin configuration: %v", err) + return &api.SchedulerCallbackResponse{Error: fmt.Sprintf("Config error: %v", err)}, nil + } + + // Check if ticker configuration exists + tickerConfig, ok := configResp.Config["tickers"] + if !ok { + log.Printf("Missing 'tickers' configuration") + return &api.SchedulerCallbackResponse{Error: "Missing 'tickers' configuration"}, nil + } + + // Parse ticker symbols + tickers := parseTickerSymbols(tickerConfig) + log.Printf("Reconnecting with tickers: %v", tickers) + + // Try to connect and subscribe + err = connectAndSubscribe(ctx, tickers) + if err != nil { + log.Printf("Reconnection attempt failed: %v", err) + return &api.SchedulerCallbackResponse{Error: err.Error()}, nil + } + + // Successfully reconnected, cancel the reconnection schedule + _, err = schedService.CancelSchedule(ctx, &scheduler.CancelRequest{ + ScheduleId: reconnectScheduleID, + }) + + if err != nil { + log.Printf("Failed to cancel reconnection schedule: %v", err) + } else { + log.Printf("Reconnection schedule canceled after successful reconnection") + } + + return &api.SchedulerCallbackResponse{}, nil +} + +// Helper function to calculate percent change +func calculatePercentChange(open, current string) string { + var openFloat, currentFloat float64 + _, err := fmt.Sscanf(open, "%f", &openFloat) + if err != nil { + return "N/A" + } + _, err = fmt.Sscanf(current, "%f", ¤tFloat) + if err != nil { + return "N/A" + } + + if openFloat == 0 { + return "N/A" + } + + change := ((currentFloat - openFloat) / openFloat) * 100 + return fmt.Sprintf("%.2f", change) +} + +// Required by Go WASI build +func main() {} + +func init() { + // Configure logging: No timestamps, no source file/line, prepend [Crypto] + log.SetFlags(0) + log.SetPrefix("[Crypto] ") + + api.RegisterWebSocketCallback(CryptoTickerPlugin{}) + api.RegisterLifecycleManagement(CryptoTickerPlugin{}) + api.RegisterSchedulerCallback(CryptoTickerPlugin{}) +} diff --git a/plugins/examples/discord-rich-presence/README.md b/plugins/examples/discord-rich-presence/README.md new file mode 100644 index 0000000..80b1216 --- /dev/null +++ b/plugins/examples/discord-rich-presence/README.md @@ -0,0 +1,88 @@ +# Discord Rich Presence Plugin + +This example plugin integrates Navidrome with Discord Rich Presence. It shows how a plugin can keep a real-time +connection to an external service while remaining completely stateless. This plugin is based on the +[Navicord](https://github.com/logixism/navicord) project, which provides a similar functionality. + +**NOTE: This plugin is for demonstration purposes only. It relies on the user's Discord token being stored in the +Navidrome configuration file, which is not secure, and may be against Discord's terms of service. +Use it at your own risk.** + +## Overview + +The plugin exposes three capabilities: + +- **Scrobbler** – receives `NowPlaying` notifications from Navidrome +- **WebSocketCallback** – handles Discord gateway messages +- **SchedulerCallback** – used to clear presence and send periodic heartbeats + +It relies on several host services declared in `manifest.json`: + +- `http` – queries Discord API endpoints +- `websocket` – maintains gateway connections +- `scheduler` – schedules heartbeats and presence cleanup +- `cache` – stores sequence numbers for heartbeats +- `config` – retrieves the plugin configuration on each call +- `artwork` – resolves track artwork URLs + +## Architecture + +Each call from Navidrome creates a new plugin instance. The `init` function registers the capabilities and obtains the +scheduler service: + +```go +api.RegisterScrobbler(plugin) +api.RegisterWebSocketCallback(plugin.rpc) +plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin) +plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc) +``` + +When `NowPlaying` is invoked the plugin: + +1. Loads `clientid` and user tokens from the configuration (because plugins are stateless). +2. Connects to Discord using `WebSocketService` if no connection exists. +3. Sends the activity payload with track details and artwork. +4. Schedules a one‑time callback to clear the presence after the track finishes. + +Heartbeat messages are sent by a recurring scheduler job. Sequence numbers received from Discord are stored in +`CacheService` to remain available across plugin instances. + +The `OnSchedulerCallback` method clears the presence and closes the connection when the scheduled time is reached. + +```go +// The plugin is stateless, we need to load the configuration every time +clientID, users, err := d.getConfig(ctx) +``` + +## Configuration + +Add the following to `navidrome.toml` and adjust for your tokens: + +```toml +[PluginConfig.discord-rich-presence] +ClientID = "123456789012345678" +Users = "alice:token123,bob:token456" +``` + +- `clientid` is your Discord application ID +- `users` is a comma‑separated list of `username:token` pairs used for authorization + +## Building + +```sh +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm ./discord-rich-presence/... +``` + +Place the resulting `plugin.wasm` and `manifest.json` in a `discord-rich-presence` folder under your Navidrome plugins +directory. + +## Stateless Operation + +Navidrome plugins are completely stateless – each method call instantiates a new plugin instance and discards it +afterwards. + +To work within this model the plugin stores no in-memory state. Connections are keyed by user name inside the host +services and any transient data (like Discord sequence numbers) is kept in the cache. Configuration is reloaded on every +method call. + +For more implementation details see `plugin.go` and `rpc.go`. diff --git a/plugins/examples/discord-rich-presence/manifest.json b/plugins/examples/discord-rich-presence/manifest.json new file mode 100644 index 0000000..c6fa9c2 --- /dev/null +++ b/plugins/examples/discord-rich-presence/manifest.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "discord-rich-presence", + "author": "Navidrome Team", + "version": "1.0.0", + "description": "Discord Rich Presence integration for Navidrome", + "website": "https://github.com/navidrome/navidrome/tree/master/plugins/examples/discord-rich-presence", + "capabilities": ["Scrobbler", "SchedulerCallback", "WebSocketCallback"], + "permissions": { + "http": { + "reason": "To communicate with Discord API for gateway discovery and image uploads", + "allowedUrls": { + "https://discord.com/api/*": ["GET", "POST"] + }, + "allowLocalNetwork": false + }, + "websocket": { + "reason": "To maintain real-time connection with Discord gateway", + "allowedUrls": ["wss://gateway.discord.gg"], + "allowLocalNetwork": false + }, + "config": { + "reason": "To access plugin configuration (client ID and user tokens)" + }, + "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" + } + } +} diff --git a/plugins/examples/discord-rich-presence/plugin.go b/plugins/examples/discord-rich-presence/plugin.go new file mode 100644 index 0000000..c93ccf3 --- /dev/null +++ b/plugins/examples/discord-rich-presence/plugin.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "fmt" + "log" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/artwork" + "github.com/navidrome/navidrome/plugins/host/cache" + "github.com/navidrome/navidrome/plugins/host/config" + "github.com/navidrome/navidrome/plugins/host/http" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/websocket" + "github.com/navidrome/navidrome/utils/slice" +) + +type DiscordRPPlugin struct { + rpc *discordRPC + cfg config.ConfigService + artwork artwork.ArtworkService + sched scheduler.SchedulerService +} + +func (d *DiscordRPPlugin) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) { + // Get plugin configuration + _, users, err := d.getConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to check user authorization: %w", err) + } + + // Check if the user has a Discord token configured + _, authorized := users[req.Username] + log.Printf("IsAuthorized for user %s: %v", req.Username, authorized) + return &api.ScrobblerIsAuthorizedResponse{ + Authorized: authorized, + }, nil +} + +func (d *DiscordRPPlugin) NowPlaying(ctx context.Context, request *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) { + log.Printf("Setting presence for user %s, track: %s", request.Username, request.Track.Name) + + // The plugin is stateless, we need to load the configuration every time + clientID, users, err := d.getConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get config: %w", err) + } + + // Check if the user has a Discord token configured + userToken, authorized := users[request.Username] + if !authorized { + return nil, fmt.Errorf("user '%s' not authorized", request.Username) + } + + // Make sure we have a connection + if err := d.rpc.connect(ctx, request.Username, userToken); err != nil { + return nil, fmt.Errorf("failed to connect to Discord: %w", err) + } + + // Cancel any existing completion schedule + if resp, _ := d.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: request.Username}); resp.Error != "" { + log.Printf("Ignoring failure to cancel schedule: %s", resp.Error) + } + + // Send activity update + if err := d.rpc.sendActivity(ctx, clientID, request.Username, userToken, activity{ + Application: clientID, + Name: "Navidrome", + Type: 2, + Details: request.Track.Name, + State: d.getArtistList(request.Track), + Timestamps: activityTimestamps{ + Start: (request.Timestamp - int64(request.Track.Position)) * 1000, + End: (request.Timestamp - int64(request.Track.Position) + int64(request.Track.Length)) * 1000, + }, + Assets: activityAssets{ + LargeImage: d.imageURL(ctx, request), + LargeText: request.Track.Album, + }, + }); err != nil { + return nil, fmt.Errorf("failed to send activity: %w", err) + } + + // Schedule a timer to clear the activity after the track completes + _, err = d.sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{ + ScheduleId: request.Username, + DelaySeconds: request.Track.Length - request.Track.Position + 5, + }) + if err != nil { + return nil, fmt.Errorf("failed to schedule completion timer: %w", err) + } + + return nil, nil +} + +func (d *DiscordRPPlugin) imageURL(ctx context.Context, request *api.ScrobblerNowPlayingRequest) string { + imageResp, _ := d.artwork.GetTrackUrl(ctx, &artwork.GetArtworkUrlRequest{Id: request.Track.Id, Size: 300}) + imageURL := imageResp.Url + if strings.HasPrefix(imageURL, "http://localhost") { + return "" + } + return imageURL +} + +func (d *DiscordRPPlugin) getArtistList(track *api.TrackInfo) string { + return strings.Join(slice.Map(track.Artists, func(a *api.Artist) string { return a.Name }), " • ") +} + +func (d *DiscordRPPlugin) Scrobble(context.Context, *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) { + return nil, nil +} + +func (d *DiscordRPPlugin) getConfig(ctx context.Context) (string, map[string]string, error) { + const ( + clientIDKey = "clientid" + usersKey = "users" + ) + confResp, err := d.cfg.GetPluginConfig(ctx, &config.GetPluginConfigRequest{}) + if err != nil { + return "", nil, fmt.Errorf("unable to load config: %w", err) + } + conf := confResp.GetConfig() + if len(conf) < 1 { + log.Print("missing configuration") + return "", nil, nil + } + clientID := conf[clientIDKey] + if clientID == "" { + log.Printf("missing ClientID: %v", conf) + return "", nil, nil + } + cfgUsers := conf[usersKey] + if len(cfgUsers) == 0 { + log.Print("no users configured") + return "", nil, nil + } + users := map[string]string{} + for _, user := range strings.Split(cfgUsers, ",") { + tuple := strings.Split(user, ":") + if len(tuple) != 2 { + return clientID, nil, fmt.Errorf("invalid user config: %s", user) + } + users[tuple[0]] = tuple[1] + } + return clientID, users, nil +} + +func (d *DiscordRPPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + log.Printf("Removing presence for user %s", req.ScheduleId) + if err := d.rpc.clearActivity(ctx, req.ScheduleId); err != nil { + return nil, fmt.Errorf("failed to clear activity: %w", err) + } + log.Printf("Disconnecting user %s", req.ScheduleId) + if err := d.rpc.disconnect(ctx, req.ScheduleId); err != nil { + return nil, fmt.Errorf("failed to disconnect from Discord: %w", err) + } + return nil, nil +} + +// Creates a new instance of the DiscordRPPlugin, with all host services as dependencies +var plugin = &DiscordRPPlugin{ + cfg: config.NewConfigService(), + artwork: artwork.NewArtworkService(), + rpc: &discordRPC{ + ws: websocket.NewWebSocketService(), + web: http.NewHttpService(), + mem: cache.NewCacheService(), + }, +} + +func init() { + // Configure logging: No timestamps, no source file/line, prepend [Discord] + log.SetFlags(0) + log.SetPrefix("[Discord] ") + + // Register plugin capabilities + api.RegisterScrobbler(plugin) + api.RegisterWebSocketCallback(plugin.rpc) + + // Register named scheduler callbacks, and get the scheduler service for each + plugin.sched = api.RegisterNamedSchedulerCallback("close-activity", plugin) + plugin.rpc.sched = api.RegisterNamedSchedulerCallback("heartbeat", plugin.rpc) +} + +func main() {} diff --git a/plugins/examples/discord-rich-presence/rpc.go b/plugins/examples/discord-rich-presence/rpc.go new file mode 100644 index 0000000..4fab42f --- /dev/null +++ b/plugins/examples/discord-rich-presence/rpc.go @@ -0,0 +1,402 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/cache" + "github.com/navidrome/navidrome/plugins/host/http" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/websocket" +) + +type discordRPC struct { + ws websocket.WebSocketService + web http.HttpService + mem cache.CacheService + sched scheduler.SchedulerService +} + +// Discord WebSocket Gateway constants +const ( + heartbeatOpCode = 1 // Heartbeat operation code + gateOpCode = 2 // Identify operation code + presenceOpCode = 3 // Presence update operation code +) + +const ( + heartbeatInterval = 41 // Heartbeat interval in seconds + defaultImage = "https://i.imgur.com/hb3XPzA.png" +) + +// Activity is a struct that represents an activity in Discord. +type activity struct { + Name string `json:"name"` + Type int `json:"type"` + Details string `json:"details"` + State string `json:"state"` + Application string `json:"application_id"` + Timestamps activityTimestamps `json:"timestamps"` + Assets activityAssets `json:"assets"` +} + +type activityTimestamps struct { + Start int64 `json:"start"` + End int64 `json:"end"` +} + +type activityAssets struct { + LargeImage string `json:"large_image"` + LargeText string `json:"large_text"` +} + +// PresencePayload is a struct that represents a presence update in Discord. +type presencePayload struct { + Activities []activity `json:"activities"` + Since int64 `json:"since"` + Status string `json:"status"` + Afk bool `json:"afk"` +} + +// IdentifyPayload is a struct that represents an identify payload in Discord. +type identifyPayload struct { + Token string `json:"token"` + Intents int `json:"intents"` + Properties identifyProperties `json:"properties"` +} + +type identifyProperties struct { + OS string `json:"os"` + Browser string `json:"browser"` + Device string `json:"device"` +} + +func (r *discordRPC) processImage(ctx context.Context, imageURL string, clientID string, token string) (string, error) { + return r.processImageWithFallback(ctx, imageURL, clientID, token, false) +} + +func (r *discordRPC) processImageWithFallback(ctx context.Context, imageURL string, clientID string, token string, isDefaultImage bool) (string, error) { + // Check if context is canceled + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("context canceled: %w", err) + } + + if imageURL == "" { + if isDefaultImage { + // We're already processing the default image and it's empty, return error + return "", fmt.Errorf("default image URL is empty") + } + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + if strings.HasPrefix(imageURL, "mp:") { + return imageURL, nil + } + + // Check cache first + cacheKey := fmt.Sprintf("discord.image.%x", imageURL) + cacheResp, _ := r.mem.GetString(ctx, &cache.GetRequest{Key: cacheKey}) + if cacheResp.Exists { + log.Printf("Cache hit for image URL: %s", imageURL) + return cacheResp.Value, nil + } + + resp, _ := r.web.Post(ctx, &http.HttpRequest{ + Url: fmt.Sprintf("https://discord.com/api/v9/applications/%s/external-assets", clientID), + Headers: map[string]string{ + "Authorization": token, + "Content-Type": "application/json", + }, + Body: fmt.Appendf(nil, `{"urls":[%q]}`, imageURL), + }) + + // Handle HTTP error responses + if resp.Status >= 400 { + if isDefaultImage { + return "", fmt.Errorf("failed to process default image: HTTP %d %s", resp.Status, resp.Error) + } + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + if resp.Error != "" { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("failed to process default image: %s", resp.Error) + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + var data []map[string]string + if err := json.Unmarshal(resp.Body, &data); err != nil { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("failed to unmarshal default image response: %w", err) + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + if len(data) == 0 { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("no data returned for default image") + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + image := data[0]["external_asset_path"] + if image == "" { + if isDefaultImage { + // If we're already processing the default image and it fails, return error + return "", fmt.Errorf("empty external_asset_path for default image") + } + // Try with default image + return r.processImageWithFallback(ctx, defaultImage, clientID, token, true) + } + + processedImage := fmt.Sprintf("mp:%s", image) + + // Cache the processed image URL + var ttl = 4 * time.Hour // 4 hours for regular images + if isDefaultImage { + ttl = 48 * time.Hour // 48 hours for default image + } + + _, _ = r.mem.SetString(ctx, &cache.SetStringRequest{ + Key: cacheKey, + Value: processedImage, + TtlSeconds: int64(ttl.Seconds()), + }) + + log.Printf("Cached processed image URL for %s (TTL: %s seconds)", imageURL, ttl) + + return processedImage, nil +} + +func (r *discordRPC) sendActivity(ctx context.Context, clientID, username, token string, data activity) error { + log.Printf("Sending activity to for user %s: %#v", username, data) + + processedImage, err := r.processImage(ctx, data.Assets.LargeImage, clientID, token) + if err != nil { + log.Printf("Failed to process image for user %s, continuing without image: %v", username, err) + // Clear the image and continue without it + data.Assets.LargeImage = "" + } else { + log.Printf("Processed image for URL %s: %s", data.Assets.LargeImage, processedImage) + data.Assets.LargeImage = processedImage + } + + presence := presencePayload{ + Activities: []activity{data}, + Status: "dnd", + Afk: false, + } + return r.sendMessage(ctx, username, presenceOpCode, presence) +} + +func (r *discordRPC) clearActivity(ctx context.Context, username string) error { + log.Printf("Clearing activity for user %s", username) + return r.sendMessage(ctx, username, presenceOpCode, presencePayload{}) +} + +func (r *discordRPC) sendMessage(ctx context.Context, username string, opCode int, payload any) error { + message := map[string]any{ + "op": opCode, + "d": payload, + } + b, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal presence update: %w", err) + } + + resp, _ := r.ws.SendText(ctx, &websocket.SendTextRequest{ + ConnectionId: username, + Message: string(b), + }) + if resp.Error != "" { + return fmt.Errorf("failed to send presence update: %s", resp.Error) + } + return nil +} + +func (r *discordRPC) getDiscordGateway(ctx context.Context) (string, error) { + resp, _ := r.web.Get(ctx, &http.HttpRequest{ + Url: "https://discord.com/api/gateway", + }) + if resp.Error != "" { + return "", fmt.Errorf("failed to get Discord gateway: %s", resp.Error) + } + var result map[string]string + err := json.Unmarshal(resp.Body, &result) + if err != nil { + return "", fmt.Errorf("failed to parse Discord gateway response: %w", err) + } + return result["url"], nil +} + +func (r *discordRPC) sendHeartbeat(ctx context.Context, username string) error { + resp, _ := r.mem.GetInt(ctx, &cache.GetRequest{ + Key: fmt.Sprintf("discord.seq.%s", username), + }) + log.Printf("Sending heartbeat for user %s: %d", username, resp.Value) + return r.sendMessage(ctx, username, heartbeatOpCode, resp.Value) +} + +func (r *discordRPC) cleanupFailedConnection(ctx context.Context, username string) { + log.Printf("Cleaning up failed connection for user %s", username) + + // Cancel the heartbeat schedule + if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" { + log.Printf("Failed to cancel heartbeat schedule for user %s: %s", username, resp.Error) + } + + // Close the WebSocket connection + if resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{ + ConnectionId: username, + Code: 1000, + Reason: "Connection lost", + }); resp.Error != "" { + log.Printf("Failed to close WebSocket connection for user %s: %s", username, resp.Error) + } + + // Clean up cache entries (just the sequence number, no failure tracking needed) + _, _ = r.mem.Remove(ctx, &cache.RemoveRequest{Key: fmt.Sprintf("discord.seq.%s", username)}) + + log.Printf("Cleaned up connection for user %s", username) +} + +func (r *discordRPC) isConnected(ctx context.Context, username string) bool { + // Try to send a heartbeat to test the connection + err := r.sendHeartbeat(ctx, username) + if err != nil { + log.Printf("Heartbeat test failed for user %s: %v", username, err) + return false + } + return true +} + +func (r *discordRPC) connect(ctx context.Context, username string, token string) error { + if r.isConnected(ctx, username) { + log.Printf("Reusing existing connection for user %s", username) + return nil + } + log.Printf("Creating new connection for user %s", username) + + // Get Discord Gateway URL + gateway, err := r.getDiscordGateway(ctx) + if err != nil { + return fmt.Errorf("failed to get Discord gateway: %w", err) + } + log.Printf("Using gateway: %s", gateway) + + // Connect to Discord Gateway + resp, _ := r.ws.Connect(ctx, &websocket.ConnectRequest{ + ConnectionId: username, + Url: gateway, + }) + if resp.Error != "" { + return fmt.Errorf("failed to connect to WebSocket: %s", resp.Error) + } + + // Send identify payload + payload := identifyPayload{ + Token: token, + Intents: 0, + Properties: identifyProperties{ + OS: "Windows 10", + Browser: "Discord Client", + Device: "Discord Client", + }, + } + err = r.sendMessage(ctx, username, gateOpCode, payload) + if err != nil { + return fmt.Errorf("failed to send identify payload: %w", err) + } + + // Schedule heartbeats for this user/connection + cronResp, _ := r.sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + CronExpression: fmt.Sprintf("@every %ds", heartbeatInterval), + ScheduleId: username, + }) + log.Printf("Scheduled heartbeat for user %s with ID %s", username, cronResp.ScheduleId) + + log.Printf("Successfully authenticated user %s", username) + return nil +} + +func (r *discordRPC) disconnect(ctx context.Context, username string) error { + if resp, _ := r.sched.CancelSchedule(ctx, &scheduler.CancelRequest{ScheduleId: username}); resp.Error != "" { + return fmt.Errorf("failed to cancel schedule: %s", resp.Error) + } + resp, _ := r.ws.Close(ctx, &websocket.CloseRequest{ + ConnectionId: username, + Code: 1000, + Reason: "Navidrome disconnect", + }) + if resp.Error != "" { + return fmt.Errorf("failed to close WebSocket connection: %s", resp.Error) + } + return nil +} + +func (r *discordRPC) OnTextMessage(ctx context.Context, req *api.OnTextMessageRequest) (*api.OnTextMessageResponse, error) { + if len(req.Message) < 1024 { + log.Printf("Received WebSocket message for connection '%s': %s", req.ConnectionId, req.Message) + } else { + log.Printf("Received WebSocket message for connection '%s' (truncated): %s...", req.ConnectionId, req.Message[:1021]) + } + + // Parse the message. If it's a heartbeat_ack, store the sequence number. + message := map[string]any{} + err := json.Unmarshal([]byte(req.Message), &message) + if err != nil { + return nil, fmt.Errorf("failed to parse WebSocket message: %w", err) + } + if v := message["s"]; v != nil { + seq := int64(v.(float64)) + log.Printf("Received heartbeat_ack for connection '%s': %d", req.ConnectionId, seq) + resp, _ := r.mem.SetInt(ctx, &cache.SetIntRequest{ + Key: fmt.Sprintf("discord.seq.%s", req.ConnectionId), + Value: seq, + TtlSeconds: heartbeatInterval * 2, + }) + if !resp.Success { + return nil, fmt.Errorf("failed to store sequence number for user %s", req.ConnectionId) + } + } + return nil, nil +} + +func (r *discordRPC) OnBinaryMessage(_ context.Context, req *api.OnBinaryMessageRequest) (*api.OnBinaryMessageResponse, error) { + log.Printf("Received unexpected binary message for connection '%s'", req.ConnectionId) + return nil, nil +} + +func (r *discordRPC) OnError(_ context.Context, req *api.OnErrorRequest) (*api.OnErrorResponse, error) { + log.Printf("WebSocket error for connection '%s': %s", req.ConnectionId, req.Error) + return nil, nil +} + +func (r *discordRPC) OnClose(_ context.Context, req *api.OnCloseRequest) (*api.OnCloseResponse, error) { + log.Printf("WebSocket connection '%s' closed with code %d: %s", req.ConnectionId, req.Code, req.Reason) + return nil, nil +} + +func (r *discordRPC) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + err := r.sendHeartbeat(ctx, req.ScheduleId) + if err != nil { + // On first heartbeat failure, immediately clean up the connection + // The next NowPlaying call will reconnect if needed + log.Printf("Heartbeat failed for user %s, cleaning up connection: %v", req.ScheduleId, err) + r.cleanupFailedConnection(ctx, req.ScheduleId) + return nil, fmt.Errorf("heartbeat failed, connection cleaned up: %w", err) + } + + return nil, nil +} diff --git a/plugins/examples/subsonicapi-demo/README.md b/plugins/examples/subsonicapi-demo/README.md new file mode 100644 index 0000000..b5ac9f7 --- /dev/null +++ b/plugins/examples/subsonicapi-demo/README.md @@ -0,0 +1,88 @@ +# SubsonicAPI Demo Plugin + +This example plugin demonstrates how to use the SubsonicAPI host service to access Navidrome's Subsonic API from within a plugin. + +## What it does + +The plugin performs the following operations during initialization: + +1. **Ping the server**: Calls `/rest/ping` to check if the Subsonic API is responding +2. **Get license info**: Calls `/rest/getLicense` to retrieve server license information + +## Key Features + +- Shows how to request `subsonicapi` permission in the manifest +- Demonstrates making Subsonic API calls using the `subsonicapi.Call()` method +- Handles both successful responses and errors +- Uses proper lifecycle management with `OnInit` + +## Usage + +### Manifest Configuration + +```json +{ + "permissions": { + "subsonicapi": { + "reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins", + "allowAdmins": true + } + } +} +``` + +### Plugin Implementation + +```go +import "github.com/navidrome/navidrome/plugins/host/subsonicapi" + +var subsonicService = subsonicapi.NewSubsonicAPIService() + +// OnInit is called when the plugin is loaded +func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + // Make API calls + response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + }) + // Handle response... +} +``` + +When running Navidrome with this plugin installed, it will automatically call the Subsonic API endpoints during the +server startup, and you can see the results in the logs: + +```agsl +INFO[0000] 2022/01/01 00:00:00 SubsonicAPI Demo Plugin initializing... +DEBU[0000] API: New request /ping client=subsonicapi-demo username=admin version=1.16.1 +DEBU[0000] API: Successful response endpoint=/ping status=OK +DEBU[0000] API: New request /getLicense client=subsonicapi-demo username=admin version=1.16.1 +INFO[0000] 2022/01/01 00:00:00 SubsonicAPI ping response: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true}} +DEBU[0000] API: Successful response endpoint=/getLicense status=OK +DEBU[0000] Plugin initialized successfully elapsed=41.9ms plugin=subsonicapi-demo +INFO[0000] 2022/01/01 00:00:00 SubsonicAPI license info: {"subsonic-response":{"status":"ok","version":"1.16.1","type":"navidrome","serverVersion":"dev","openSubsonic":true,"license":{"valid":true}}} +``` + +## Important Notes + +1. **Authentication**: The plugin must provide valid authentication parameters in the URL: + - **Required**: `u` (username) - The service validates this parameter is present + - Example: `"/rest/ping?u=admin"` +2. **URL Format**: Only the path and query parameters from the URL are used - host, protocol, and method are ignored +3. **Automatic Parameters**: The service automatically adds: + - `c`: Plugin name (client identifier) + - `v`: Subsonic API version (1.16.1) + - `f`: Response format (json) +4. **Internal Authentication**: The service sets up internal authentication using the `u` parameter +5. **Lifecycle**: This plugin uses `LifecycleManagement` with only the `OnInit` method + +## Building + +This plugin uses the `wasip1` build constraint and must be compiled for WebAssembly: + +```bash +# Using the project's make target (recommended) +make plugin-examples + +# Manual compilation (when using the proper toolchain) +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go +``` diff --git a/plugins/examples/subsonicapi-demo/manifest.json b/plugins/examples/subsonicapi-demo/manifest.json new file mode 100644 index 0000000..d26c331 --- /dev/null +++ b/plugins/examples/subsonicapi-demo/manifest.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "subsonicapi-demo", + "author": "Navidrome Team", + "version": "1.0.0", + "description": "Example plugin demonstrating SubsonicAPI host service usage", + "website": "https://github.com/navidrome/navidrome", + "capabilities": ["LifecycleManagement"], + "permissions": { + "subsonicapi": { + "reason": "Demonstrate accessing Navidrome's Subsonic API from within plugins", + "allowAdmins": true, + "allowedUsernames": ["admin"] + } + } +} diff --git a/plugins/examples/subsonicapi-demo/plugin.go b/plugins/examples/subsonicapi-demo/plugin.go new file mode 100644 index 0000000..4ca087a --- /dev/null +++ b/plugins/examples/subsonicapi-demo/plugin.go @@ -0,0 +1,68 @@ +//go:build wasip1 + +package main + +import ( + "context" + "log" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" +) + +// SubsonicAPIService instance for making API calls +var subsonicService = subsonicapi.NewSubsonicAPIService() + +// SubsonicAPIDemoPlugin implements LifecycleManagement interface +type SubsonicAPIDemoPlugin struct{} + +// OnInit is called when the plugin is loaded +func (SubsonicAPIDemoPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("SubsonicAPI Demo Plugin initializing...") + + // Example: Call the ping endpoint to check if the server is alive + response, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + }) + + if err != nil { + log.Printf("SubsonicAPI call failed: %v", err) + return &api.InitResponse{Error: err.Error()}, nil + } + + if response.Error != "" { + log.Printf("SubsonicAPI returned error: %s", response.Error) + return &api.InitResponse{Error: response.Error}, nil + } + + log.Printf("SubsonicAPI ping response: %s", response.Json) + + // Example: Get server info + infoResponse, err := subsonicService.Call(ctx, &subsonicapi.CallRequest{ + Url: "/rest/getLicense?u=admin", + }) + + if err != nil { + log.Printf("SubsonicAPI getLicense call failed: %v", err) + return &api.InitResponse{Error: err.Error()}, nil + } + + if infoResponse.Error != "" { + log.Printf("SubsonicAPI getLicense returned error: %s", infoResponse.Error) + return &api.InitResponse{Error: infoResponse.Error}, nil + } + + log.Printf("SubsonicAPI license info: %s", infoResponse.Json) + + return &api.InitResponse{}, nil +} + +func main() {} + +func init() { + // Configure logging: No timestamps, no source file/line + log.SetFlags(0) + log.SetPrefix("[Subsonic Plugin] ") + + api.RegisterLifecycleManagement(&SubsonicAPIDemoPlugin{}) +} diff --git a/plugins/examples/wikimedia/README.md b/plugins/examples/wikimedia/README.md new file mode 100644 index 0000000..15feed2 --- /dev/null +++ b/plugins/examples/wikimedia/README.md @@ -0,0 +1,32 @@ +# Wikimedia Artist Metadata Plugin + +This is a WASM plugin for Navidrome that retrieves artist information from Wikidata/DBpedia using the Wikidata SPARQL endpoint. + +## Implemented Methods + +- `GetArtistBiography`: Returns the artist's English biography/description from Wikidata. +- `GetArtistURL`: Returns the artist's official website (if available) from Wikidata. +- `GetArtistImages`: Returns the artist's main image (Wikimedia Commons) from Wikidata. + +All other methods (`GetArtistMBID`, `GetSimilarArtists`, `GetArtistTopSongs`) return a "not implemented" error, as this data is not available from Wikidata/DBpedia. + +## How it Works + +- The plugin uses the host-provided HTTP service (`HttpService`) to make SPARQL queries to the Wikidata endpoint. +- No network requests are made directly from the plugin; all HTTP is routed through the host. + +## Building + +To build the plugin to WASM: + +``` +GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o plugin.wasm plugin.go +``` + +## Usage + +Copy the resulting `plugin.wasm` to your Navidrome plugins folder under a `wikimedia` directory. + +--- + +For more details, see the source code in `plugin.go`. diff --git a/plugins/examples/wikimedia/manifest.json b/plugins/examples/wikimedia/manifest.json new file mode 100644 index 0000000..5d0196e --- /dev/null +++ b/plugins/examples/wikimedia/manifest.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/navidrome/navidrome/refs/heads/master/plugins/schema/manifest.schema.json", + "name": "wikimedia", + "author": "Navidrome", + "version": "1.0.0", + "description": "Artist information and images from Wikimedia Commons", + "website": "https://commons.wikimedia.org", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch artist information and images from Wikimedia Commons API", + "allowedUrls": { + "https://*.wikimedia.org": ["GET"], + "https://*.wikipedia.org": ["GET"], + "https://commons.wikimedia.org": ["GET"] + }, + "allowLocalNetwork": false + } + } +} diff --git a/plugins/examples/wikimedia/plugin.go b/plugins/examples/wikimedia/plugin.go new file mode 100644 index 0000000..6b60e69 --- /dev/null +++ b/plugins/examples/wikimedia/plugin.go @@ -0,0 +1,391 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net/url" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/http" +) + +const ( + wikidataEndpoint = "https://query.wikidata.org/sparql" + dbpediaEndpoint = "https://dbpedia.org/sparql" + mediawikiAPIEndpoint = "https://en.wikipedia.org/w/api.php" + requestTimeoutMs = 5000 +) + +var ( + ErrNotFound = api.ErrNotFound + ErrNotImplemented = api.ErrNotImplemented + + client = http.NewHttpService() +) + +// SPARQLResult struct for all possible fields +// Only the needed field will be non-nil in each context +// (Sitelink, Wiki, Comment, Img) +type SPARQLResult struct { + Results struct { + Bindings []struct { + Sitelink *struct{ Value string } `json:"sitelink,omitempty"` + Wiki *struct{ Value string } `json:"wiki,omitempty"` + Comment *struct{ Value string } `json:"comment,omitempty"` + Img *struct{ Value string } `json:"img,omitempty"` + } `json:"bindings"` + } `json:"results"` +} + +// MediaWikiExtractResult is used to unmarshal MediaWiki API extract responses +// (for getWikipediaExtract) +type MediaWikiExtractResult struct { + Query struct { + Pages map[string]struct { + PageID int `json:"pageid"` + Ns int `json:"ns"` + Title string `json:"title"` + Extract string `json:"extract"` + Missing bool `json:"missing"` + } `json:"pages"` + } `json:"query"` +} + +// --- SPARQL Query Helper --- +func sparqlQuery(ctx context.Context, client http.HttpService, endpoint, query string) (*SPARQLResult, error) { + form := url.Values{} + form.Set("query", query) + + req := &http.HttpRequest{ + Url: endpoint, + Headers: map[string]string{ + "Accept": "application/sparql-results+json", + "Content-Type": "application/x-www-form-urlencoded", // Required by SPARQL endpoints + "User-Agent": "NavidromeWikimediaPlugin/0.1", + }, + Body: []byte(form.Encode()), // Send encoded form data + TimeoutMs: requestTimeoutMs, + } + log.Printf("[Wikimedia Query] Attempting SPARQL query to %s (query length: %d):\n%s", endpoint, len(query), query) + resp, err := client.Post(ctx, req) + if err != nil { + return nil, fmt.Errorf("SPARQL request error: %w", err) + } + if resp.Status != 200 { + log.Printf("[Wikimedia Query] SPARQL HTTP error %d for query to %s. Body: %s", resp.Status, endpoint, string(resp.Body)) + return nil, fmt.Errorf("SPARQL HTTP error: status %d", resp.Status) + } + 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, ErrNotFound + } + return &result, nil +} + +// --- MediaWiki API Helper --- +func mediawikiQuery(ctx context.Context, client http.HttpService, params url.Values) ([]byte, error) { + apiURL := fmt.Sprintf("%s?%s", mediawikiAPIEndpoint, params.Encode()) + req := &http.HttpRequest{ + Url: apiURL, + Headers: map[string]string{ + "Accept": "application/json", + "User-Agent": "NavidromeWikimediaPlugin/0.1", + }, + TimeoutMs: requestTimeoutMs, + } + resp, err := client.Get(ctx, req) + if err != nil { + return nil, fmt.Errorf("MediaWiki request error: %w", err) + } + if resp.Status != 200 { + return nil, fmt.Errorf("MediaWiki HTTP error: status %d, body: %s", resp.Status, string(resp.Body)) + } + return resp.Body, nil +} + +// --- Wikidata Fetch Functions --- +func getWikidataWikipediaURL(ctx context.Context, client http.HttpService, mbid, name string) (string, error) { + var q string + if mbid != "" { + // Using property chain: ?sitelink schema:about ?artist; schema:isPartOf . + 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, "\"", "\\\"") + // Using property chain: ?sitelink schema:about ?artist; schema:isPartOf . + 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(ctx, client, wikidataEndpoint, q) + if err != nil { + return "", fmt.Errorf("Wikidata SPARQL query failed: %w", err) + } + if result.Results.Bindings[0].Sitelink != nil { + return result.Results.Bindings[0].Sitelink.Value, nil + } + return "", ErrNotFound +} + +// --- DBpedia Fetch Functions --- +func getDBpediaWikipediaURL(ctx context.Context, client http.HttpService, name string) (string, error) { + if name == "" { + return "", ErrNotFound + } + 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(ctx, client, dbpediaEndpoint, q) + if err != nil { + return "", fmt.Errorf("DBpedia SPARQL query failed: %w", err) + } + if result.Results.Bindings[0].Wiki != nil { + return result.Results.Bindings[0].Wiki.Value, nil + } + return "", ErrNotFound +} + +func getDBpediaComment(ctx context.Context, client http.HttpService, name string) (string, error) { + if name == "" { + return "", ErrNotFound + } + 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(ctx, client, dbpediaEndpoint, q) + if err != nil { + return "", fmt.Errorf("DBpedia comment SPARQL query failed: %w", err) + } + if result.Results.Bindings[0].Comment != nil { + return result.Results.Bindings[0].Comment.Value, nil + } + return "", ErrNotFound +} + +// --- Wikipedia API Fetch Function --- +func getWikipediaExtract(ctx context.Context, client http.HttpService, pageTitle string) (string, error) { + if pageTitle == "" { + return "", errors.New("page title required for Wikipedia API lookup") + } + params := url.Values{} + params.Set("action", "query") + params.Set("format", "json") + params.Set("prop", "extracts") + params.Set("exintro", "true") // Intro section only + params.Set("explaintext", "true") // Plain text + params.Set("titles", pageTitle) + params.Set("redirects", "1") // Follow redirects + + body, err := mediawikiQuery(ctx, client, params) + if err != nil { + return "", fmt.Errorf("MediaWiki query failed: %w", err) + } + + var result MediaWikiExtractResult + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("failed to parse MediaWiki response: %w", err) + } + + // Iterate through the pages map (usually only one page) + for _, page := range result.Query.Pages { + if page.Missing { + continue // Skip missing pages + } + if page.Extract != "" { + return strings.TrimSpace(page.Extract), nil + } + } + + return "", ErrNotFound +} + +// --- Helper to get Wikipedia Page Title from 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 +} + +// --- Agent Implementation --- +type WikimediaAgent struct{} + +// GetArtistURL fetches the Wikipedia URL. +// Order: Wikidata(MBID/Name) -> DBpedia(Name) -> Search URL +func (WikimediaAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + var wikiURL string + var err error + + // 1. Try Wikidata (MBID first, then name) + wikiURL, err = getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name) + if err == nil && wikiURL != "" { + return &api.ArtistURLResponse{Url: wikiURL}, nil + } + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia] Error fetching Wikidata URL: %v\n", err) + // Don't stop, try DBpedia + } + + // 2. Try DBpedia (Name only) + if req.Name != "" { + wikiURL, err = getDBpediaWikipediaURL(ctx, client, req.Name) + if err == nil && wikiURL != "" { + return &api.ArtistURLResponse{Url: wikiURL}, nil + } + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia] Error fetching DBpedia URL: %v\n", err) + // Don't stop, generate search URL + } + } + + // 3. Fallback to search URL + if req.Name != "" { + searchURL := fmt.Sprintf("https://en.wikipedia.org/w/index.php?search=%s", url.QueryEscape(req.Name)) + log.Printf("[Wikimedia] URL not found, falling back to search URL: %s\n", searchURL) + return &api.ArtistURLResponse{Url: searchURL}, nil + } + + log.Printf("[Wikimedia] Could not determine Wikipedia URL for: %s (%s)\n", req.Name, req.Mbid) + return nil, ErrNotFound +} + +// GetArtistBiography fetches the long biography. +// Order: Wikipedia API (via Wikidata/DBpedia URL) -> DBpedia Comment (Name) +func (WikimediaAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + var bio string + var err error + + log.Printf("[Wikimedia Bio] Fetching for Name: %s, MBID: %s", req.Name, req.Mbid) + + // 1. Get Wikipedia URL (using the logic from GetArtistURL) + wikiURL := "" + // Try Wikidata first + tempURL, wdErr := getWikidataWikipediaURL(ctx, client, req.Mbid, req.Name) + if wdErr == nil && tempURL != "" { + log.Printf("[Wikimedia Bio] Found Wikidata URL: %s", tempURL) + wikiURL = tempURL + } else if req.Name != "" { + // Try DBpedia if Wikidata failed or returned not found + log.Printf("[Wikimedia Bio] Wikidata URL failed (%v), trying DBpedia URL", wdErr) + tempURL, dbErr := getDBpediaWikipediaURL(ctx, client, req.Name) + if dbErr == nil && tempURL != "" { + log.Printf("[Wikimedia Bio] Found DBpedia URL: %s", tempURL) + wikiURL = tempURL + } else { + log.Printf("[Wikimedia Bio] DBpedia URL failed (%v)", dbErr) + } + } + + // 2. If Wikipedia URL found, try MediaWiki API + if wikiURL != "" { + pageTitle, err := extractPageTitleFromURL(wikiURL) + if err == nil { + log.Printf("[Wikimedia Bio] Extracted page title: %s", pageTitle) + bio, err = getWikipediaExtract(ctx, client, pageTitle) + if err == nil && bio != "" { + log.Printf("[Wikimedia Bio] Found Wikipedia extract.") + return &api.ArtistBiographyResponse{Biography: bio}, nil + } + log.Printf("[Wikimedia Bio] Wikipedia extract failed: %v", err) + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia Bio] Error fetching Wikipedia extract for '%s': %v", pageTitle, err) + // Don't stop, try DBpedia comment + } + } else { + log.Printf("[Wikimedia Bio] Error extracting page title from URL '%s': %v", wikiURL, err) + // Don't stop, try DBpedia comment + } + } + + // 3. Fallback to DBpedia Comment (Name only) + if req.Name != "" { + log.Printf("[Wikimedia Bio] Falling back to DBpedia comment for name: %s", req.Name) + bio, err = getDBpediaComment(ctx, client, req.Name) + if err == nil && bio != "" { + log.Printf("[Wikimedia Bio] Found DBpedia comment.") + return &api.ArtistBiographyResponse{Biography: bio}, nil + } + log.Printf("[Wikimedia Bio] DBpedia comment failed: %v", err) + if err != nil && err != ErrNotFound { + log.Printf("[Wikimedia Bio] Error fetching DBpedia comment for '%s': %v", req.Name, err) + } + } + + log.Printf("[Wikimedia Bio] Final: Biography not found for: %s (%s)", req.Name, req.Mbid) + return nil, ErrNotFound +} + +// GetArtistImages fetches images (Wikidata only for now) +func (WikimediaAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + var q string + if req.Mbid != "" { + q = fmt.Sprintf(`SELECT ?img WHERE { ?artist wdt:P434 "%s"; wdt:P18 ?img } LIMIT 1`, req.Mbid) + } else if req.Name != "" { + escapedName := strings.ReplaceAll(req.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(ctx, client, wikidataEndpoint, q) + if err != nil { + log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid) + return nil, ErrNotFound + } + if result.Results.Bindings[0].Img != nil { + return &api.ArtistImageResponse{Images: []*api.ExternalImage{{Url: result.Results.Bindings[0].Img.Value, Size: 0}}}, nil + } + log.Printf("[Wikimedia] Image not found for: %s (%s)\n", req.Name, req.Mbid) + return nil, ErrNotFound +} + +// Not implemented methods +func (WikimediaAgent) GetArtistMBID(context.Context, *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, ErrNotImplemented +} +func (WikimediaAgent) GetSimilarArtists(context.Context, *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, ErrNotImplemented +} +func (WikimediaAgent) GetArtistTopSongs(context.Context, *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, ErrNotImplemented +} +func (WikimediaAgent) GetAlbumInfo(context.Context, *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + return nil, ErrNotImplemented +} + +func (WikimediaAgent) GetAlbumImages(context.Context, *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return nil, ErrNotImplemented +} + +func main() {} + +func init() { + // Configure logging: No timestamps, no source file/line + log.SetFlags(0) + log.SetPrefix("[Wikimedia] ") + + api.RegisterMetadataAgent(WikimediaAgent{}) +} diff --git a/plugins/host/artwork/artwork.pb.go b/plugins/host/artwork/artwork.pb.go new file mode 100644 index 0000000..228eced --- /dev/null +++ b/plugins/host/artwork/artwork.pb.go @@ -0,0 +1,73 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/artwork/artwork.proto + +package artwork + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetArtworkUrlRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Size int32 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` // Optional, 0 means original size +} + +func (x *GetArtworkUrlRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetArtworkUrlRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetArtworkUrlRequest) GetSize() int32 { + if x != nil { + return x.Size + } + return 0 +} + +type GetArtworkUrlResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *GetArtworkUrlResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetArtworkUrlResponse) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +// go:plugin type=host version=1 +type ArtworkService interface { + GetArtistUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) + GetAlbumUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) + GetTrackUrl(context.Context, *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) +} diff --git a/plugins/host/artwork/artwork.proto b/plugins/host/artwork/artwork.proto new file mode 100644 index 0000000..cb562e5 --- /dev/null +++ b/plugins/host/artwork/artwork.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package artwork; + +option go_package = "github.com/navidrome/navidrome/plugins/host/artwork;artwork"; + +// go:plugin type=host version=1 +service ArtworkService { + rpc GetArtistUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetAlbumUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); + rpc GetTrackUrl(GetArtworkUrlRequest) returns (GetArtworkUrlResponse); +} + +message GetArtworkUrlRequest { + string id = 1; + int32 size = 2; // Optional, 0 means original size +} + +message GetArtworkUrlResponse { + string url = 1; +} \ No newline at end of file diff --git a/plugins/host/artwork/artwork_host.pb.go b/plugins/host/artwork/artwork_host.pb.go new file mode 100644 index 0000000..346fe14 --- /dev/null +++ b/plugins/host/artwork/artwork_host.pb.go @@ -0,0 +1,130 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/artwork/artwork.proto + +package artwork + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _artworkService struct { + ArtworkService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ArtworkService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _artworkService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetArtistUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_artist_url") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetAlbumUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_album_url") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetTrackUrl), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_track_url") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _artworkService) _GetArtistUrl(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetArtworkUrlRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetArtistUrl(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _artworkService) _GetAlbumUrl(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetArtworkUrlRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetAlbumUrl(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _artworkService) _GetTrackUrl(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetArtworkUrlRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetTrackUrl(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/artwork/artwork_plugin.pb.go b/plugins/host/artwork/artwork_plugin.pb.go new file mode 100644 index 0000000..f54aac0 --- /dev/null +++ b/plugins/host/artwork/artwork_plugin.pb.go @@ -0,0 +1,90 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/artwork/artwork.proto + +package artwork + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type artworkService struct{} + +func NewArtworkService() ArtworkService { + return artworkService{} +} + +//go:wasmimport env get_artist_url +func _get_artist_url(ptr uint32, size uint32) uint64 + +func (h artworkService) GetArtistUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_artist_url(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetArtworkUrlResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_album_url +func _get_album_url(ptr uint32, size uint32) uint64 + +func (h artworkService) GetAlbumUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_album_url(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetArtworkUrlResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_track_url +func _get_track_url(ptr uint32, size uint32) uint64 + +func (h artworkService) GetTrackUrl(ctx context.Context, request *GetArtworkUrlRequest) (*GetArtworkUrlResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_track_url(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetArtworkUrlResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/artwork/artwork_plugin_dev.go b/plugins/host/artwork/artwork_plugin_dev.go new file mode 100644 index 0000000..0071f57 --- /dev/null +++ b/plugins/host/artwork/artwork_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package artwork + +func NewArtworkService() ArtworkService { + panic("not implemented") +} diff --git a/plugins/host/artwork/artwork_vtproto.pb.go b/plugins/host/artwork/artwork_vtproto.pb.go new file mode 100644 index 0000000..6a1c0ba --- /dev/null +++ b/plugins/host/artwork/artwork_vtproto.pb.go @@ -0,0 +1,425 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/artwork/artwork.proto + +package artwork + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *GetArtworkUrlRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetArtworkUrlRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetArtworkUrlRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Size != 0 { + i = encodeVarint(dAtA, i, uint64(m.Size)) + i-- + dAtA[i] = 0x10 + } + if len(m.Id) > 0 { + i -= len(m.Id) + copy(dAtA[i:], m.Id) + i = encodeVarint(dAtA, i, uint64(len(m.Id))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *GetArtworkUrlResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetArtworkUrlResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetArtworkUrlResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *GetArtworkUrlRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Id) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Size != 0 { + n += 1 + sov(uint64(m.Size)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetArtworkUrlResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *GetArtworkUrlRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetArtworkUrlRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetArtworkUrlRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Id", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Id = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Size", wireType) + } + m.Size = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Size |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetArtworkUrlResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetArtworkUrlResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetArtworkUrlResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/cache/cache.pb.go b/plugins/host/cache/cache.pb.go new file mode 100644 index 0000000..6113a89 --- /dev/null +++ b/plugins/host/cache/cache.pb.go @@ -0,0 +1,420 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/cache/cache.proto + +package cache + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Request to store a string value +type SetStringRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // String value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetStringRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetStringRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetStringRequest) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +func (x *SetStringRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Request to store an integer value +type SetIntRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // Integer value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetIntRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetIntRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetIntRequest) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *SetIntRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Request to store a float value +type SetFloatRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // Float value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetFloatRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetFloatRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetFloatRequest) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +func (x *SetFloatRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Request to store a byte slice value +type SetBytesRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key + Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // Byte slice value to store + TtlSeconds int64 `protobuf:"varint,3,opt,name=ttl_seconds,json=ttlSeconds,proto3" json:"ttl_seconds,omitempty"` // TTL in seconds, 0 means use default +} + +func (x *SetBytesRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetBytesRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +func (x *SetBytesRequest) GetValue() []byte { + if x != nil { + return x.Value + } + return nil +} + +func (x *SetBytesRequest) GetTtlSeconds() int64 { + if x != nil { + return x.TtlSeconds + } + return 0 +} + +// Response after setting a value +type SetResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful +} + +func (x *SetResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SetResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +// Request to get a value +type GetRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key +} + +func (x *GetRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +// Response containing a string value +type GetStringResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The string value (if exists is true) +} + +func (x *GetStringResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetStringResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetStringResponse) GetValue() string { + if x != nil { + return x.Value + } + return "" +} + +// Response containing an integer value +type GetIntResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value int64 `protobuf:"varint,2,opt,name=value,proto3" json:"value,omitempty"` // The integer value (if exists is true) +} + +func (x *GetIntResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetIntResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetIntResponse) GetValue() int64 { + if x != nil { + return x.Value + } + return 0 +} + +// Response containing a float value +type GetFloatResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value float64 `protobuf:"fixed64,2,opt,name=value,proto3" json:"value,omitempty"` // The float value (if exists is true) +} + +func (x *GetFloatResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetFloatResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetFloatResponse) GetValue() float64 { + if x != nil { + return x.Value + } + return 0 +} + +// Response containing a byte slice value +type GetBytesResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists + Value []byte `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"` // The byte slice value (if exists is true) +} + +func (x *GetBytesResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetBytesResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +func (x *GetBytesResponse) GetValue() []byte { + if x != nil { + return x.Value + } + return nil +} + +// Request to remove a value +type RemoveRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key +} + +func (x *RemoveRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *RemoveRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +// Response after removing a value +type RemoveResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether the operation was successful +} + +func (x *RemoveResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *RemoveResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +// Request to check if a key exists +type HasRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` // Cache key +} + +func (x *HasRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HasRequest) GetKey() string { + if x != nil { + return x.Key + } + return "" +} + +// Response indicating if a key exists +type HasResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Exists bool `protobuf:"varint,1,opt,name=exists,proto3" json:"exists,omitempty"` // Whether the key exists +} + +func (x *HasResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HasResponse) GetExists() bool { + if x != nil { + return x.Exists + } + return false +} + +// go:plugin type=host version=1 +type CacheService interface { + // Set a string value in the cache + SetString(context.Context, *SetStringRequest) (*SetResponse, error) + // Get a string value from the cache + GetString(context.Context, *GetRequest) (*GetStringResponse, error) + // Set an integer value in the cache + SetInt(context.Context, *SetIntRequest) (*SetResponse, error) + // Get an integer value from the cache + GetInt(context.Context, *GetRequest) (*GetIntResponse, error) + // Set a float value in the cache + SetFloat(context.Context, *SetFloatRequest) (*SetResponse, error) + // Get a float value from the cache + GetFloat(context.Context, *GetRequest) (*GetFloatResponse, error) + // Set a byte slice value in the cache + SetBytes(context.Context, *SetBytesRequest) (*SetResponse, error) + // Get a byte slice value from the cache + GetBytes(context.Context, *GetRequest) (*GetBytesResponse, error) + // Remove a value from the cache + Remove(context.Context, *RemoveRequest) (*RemoveResponse, error) + // Check if a key exists in the cache + Has(context.Context, *HasRequest) (*HasResponse, error) +} diff --git a/plugins/host/cache/cache.proto b/plugins/host/cache/cache.proto new file mode 100644 index 0000000..8081eca --- /dev/null +++ b/plugins/host/cache/cache.proto @@ -0,0 +1,120 @@ +syntax = "proto3"; + +package cache; + +option go_package = "github.com/navidrome/navidrome/plugins/host/cache;cache"; + +// go:plugin type=host version=1 +service CacheService { + // Set a string value in the cache + rpc SetString(SetStringRequest) returns (SetResponse); + + // Get a string value from the cache + rpc GetString(GetRequest) returns (GetStringResponse); + + // Set an integer value in the cache + rpc SetInt(SetIntRequest) returns (SetResponse); + + // Get an integer value from the cache + rpc GetInt(GetRequest) returns (GetIntResponse); + + // Set a float value in the cache + rpc SetFloat(SetFloatRequest) returns (SetResponse); + + // Get a float value from the cache + rpc GetFloat(GetRequest) returns (GetFloatResponse); + + // Set a byte slice value in the cache + rpc SetBytes(SetBytesRequest) returns (SetResponse); + + // Get a byte slice value from the cache + rpc GetBytes(GetRequest) returns (GetBytesResponse); + + // Remove a value from the cache + rpc Remove(RemoveRequest) returns (RemoveResponse); + + // Check if a key exists in the cache + rpc Has(HasRequest) returns (HasResponse); +} + +// Request to store a string value +message SetStringRequest { + string key = 1; // Cache key + string value = 2; // String value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Request to store an integer value +message SetIntRequest { + string key = 1; // Cache key + int64 value = 2; // Integer value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Request to store a float value +message SetFloatRequest { + string key = 1; // Cache key + double value = 2; // Float value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Request to store a byte slice value +message SetBytesRequest { + string key = 1; // Cache key + bytes value = 2; // Byte slice value to store + int64 ttl_seconds = 3; // TTL in seconds, 0 means use default +} + +// Response after setting a value +message SetResponse { + bool success = 1; // Whether the operation was successful +} + +// Request to get a value +message GetRequest { + string key = 1; // Cache key +} + +// Response containing a string value +message GetStringResponse { + bool exists = 1; // Whether the key exists + string value = 2; // The string value (if exists is true) +} + +// Response containing an integer value +message GetIntResponse { + bool exists = 1; // Whether the key exists + int64 value = 2; // The integer value (if exists is true) +} + +// Response containing a float value +message GetFloatResponse { + bool exists = 1; // Whether the key exists + double value = 2; // The float value (if exists is true) +} + +// Response containing a byte slice value +message GetBytesResponse { + bool exists = 1; // Whether the key exists + bytes value = 2; // The byte slice value (if exists is true) +} + +// Request to remove a value +message RemoveRequest { + string key = 1; // Cache key +} + +// Response after removing a value +message RemoveResponse { + bool success = 1; // Whether the operation was successful +} + +// Request to check if a key exists +message HasRequest { + string key = 1; // Cache key +} + +// Response indicating if a key exists +message HasResponse { + bool exists = 1; // Whether the key exists +} \ No newline at end of file diff --git a/plugins/host/cache/cache_host.pb.go b/plugins/host/cache/cache_host.pb.go new file mode 100644 index 0000000..479473f --- /dev/null +++ b/plugins/host/cache/cache_host.pb.go @@ -0,0 +1,374 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/cache/cache.proto + +package cache + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _cacheService struct { + CacheService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions CacheService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _cacheService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetString), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_string") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetString), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_string") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_int") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetInt), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_int") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_float") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetFloat), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_float") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("set_bytes") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetBytes), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_bytes") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Remove), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("remove") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Has), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("has") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +// Set a string value in the cache + +func (h _cacheService) _SetString(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetStringRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetString(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get a string value from the cache + +func (h _cacheService) _GetString(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetString(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Set an integer value in the cache + +func (h _cacheService) _SetInt(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetIntRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetInt(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get an integer value from the cache + +func (h _cacheService) _GetInt(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetInt(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Set a float value in the cache + +func (h _cacheService) _SetFloat(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetFloatRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetFloat(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get a float value from the cache + +func (h _cacheService) _GetFloat(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetFloat(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Set a byte slice value in the cache + +func (h _cacheService) _SetBytes(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SetBytesRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SetBytes(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get a byte slice value from the cache + +func (h _cacheService) _GetBytes(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetBytes(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Remove a value from the cache + +func (h _cacheService) _Remove(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(RemoveRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Remove(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Check if a key exists in the cache + +func (h _cacheService) _Has(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HasRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Has(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/cache/cache_plugin.pb.go b/plugins/host/cache/cache_plugin.pb.go new file mode 100644 index 0000000..6e3bdcd --- /dev/null +++ b/plugins/host/cache/cache_plugin.pb.go @@ -0,0 +1,251 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/cache/cache.proto + +package cache + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type cacheService struct{} + +func NewCacheService() CacheService { + return cacheService{} +} + +//go:wasmimport env set_string +func _set_string(ptr uint32, size uint32) uint64 + +func (h cacheService) SetString(ctx context.Context, request *SetStringRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_string(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_string +func _get_string(ptr uint32, size uint32) uint64 + +func (h cacheService) GetString(ctx context.Context, request *GetRequest) (*GetStringResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_string(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetStringResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env set_int +func _set_int(ptr uint32, size uint32) uint64 + +func (h cacheService) SetInt(ctx context.Context, request *SetIntRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_int(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_int +func _get_int(ptr uint32, size uint32) uint64 + +func (h cacheService) GetInt(ctx context.Context, request *GetRequest) (*GetIntResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_int(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetIntResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env set_float +func _set_float(ptr uint32, size uint32) uint64 + +func (h cacheService) SetFloat(ctx context.Context, request *SetFloatRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_float(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_float +func _get_float(ptr uint32, size uint32) uint64 + +func (h cacheService) GetFloat(ctx context.Context, request *GetRequest) (*GetFloatResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_float(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetFloatResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env set_bytes +func _set_bytes(ptr uint32, size uint32) uint64 + +func (h cacheService) SetBytes(ctx context.Context, request *SetBytesRequest) (*SetResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _set_bytes(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SetResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env get_bytes +func _get_bytes(ptr uint32, size uint32) uint64 + +func (h cacheService) GetBytes(ctx context.Context, request *GetRequest) (*GetBytesResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_bytes(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetBytesResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env remove +func _remove(ptr uint32, size uint32) uint64 + +func (h cacheService) Remove(ctx context.Context, request *RemoveRequest) (*RemoveResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _remove(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(RemoveResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env has +func _has(ptr uint32, size uint32) uint64 + +func (h cacheService) Has(ctx context.Context, request *HasRequest) (*HasResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _has(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HasResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/cache/cache_plugin_dev.go b/plugins/host/cache/cache_plugin_dev.go new file mode 100644 index 0000000..824dcc7 --- /dev/null +++ b/plugins/host/cache/cache_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package cache + +func NewCacheService() CacheService { + panic("not implemented") +} diff --git a/plugins/host/cache/cache_vtproto.pb.go b/plugins/host/cache/cache_vtproto.pb.go new file mode 100644 index 0000000..0ee3d9f --- /dev/null +++ b/plugins/host/cache/cache_vtproto.pb.go @@ -0,0 +1,2352 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/cache/cache.proto + +package cache + +import ( + binary "encoding/binary" + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + math "math" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *SetStringRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetStringRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetStringRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetIntRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetIntRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetIntRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if m.Value != 0 { + i = encodeVarint(dAtA, i, uint64(m.Value)) + i-- + dAtA[i] = 0x10 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetFloatRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetFloatRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetFloatRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if m.Value != 0 { + i -= 8 + binary.LittleEndian.PutUint64(dAtA[i:], uint64(math.Float64bits(float64(m.Value)))) + i-- + dAtA[i] = 0x11 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetBytesRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetBytesRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetBytesRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.TtlSeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.TtlSeconds)) + i-- + dAtA[i] = 0x18 + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SetResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SetResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SetResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Success { + i-- + if m.Success { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *GetStringResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetStringResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetStringResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetIntResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetIntResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetIntResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Value != 0 { + i = encodeVarint(dAtA, i, uint64(m.Value)) + i-- + dAtA[i] = 0x10 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetFloatResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetFloatResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetFloatResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Value != 0 { + i -= 8 + binary.LittleEndian.PutUint64(dAtA[i:], uint64(math.Float64bits(float64(m.Value)))) + i-- + dAtA[i] = 0x11 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *GetBytesResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetBytesResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetBytesResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Value) > 0 { + i -= len(m.Value) + copy(dAtA[i:], m.Value) + i = encodeVarint(dAtA, i, uint64(len(m.Value))) + i-- + dAtA[i] = 0x12 + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *RemoveRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *RemoveRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *RemoveRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *RemoveResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *RemoveResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *RemoveResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Success { + i-- + if m.Success { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *HasRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HasRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HasRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Key) > 0 { + i -= len(m.Key) + copy(dAtA[i:], m.Key) + i = encodeVarint(dAtA, i, uint64(len(m.Key))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *HasResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HasResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HasResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if m.Exists { + i-- + if m.Exists { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *SetStringRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetIntRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Value != 0 { + n += 1 + sov(uint64(m.Value)) + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetFloatRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Value != 0 { + n += 9 + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetBytesRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.TtlSeconds != 0 { + n += 1 + sov(uint64(m.TtlSeconds)) + } + n += len(m.unknownFields) + return n +} + +func (m *SetResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Success { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func (m *GetRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetStringResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetIntResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + if m.Value != 0 { + n += 1 + sov(uint64(m.Value)) + } + n += len(m.unknownFields) + return n +} + +func (m *GetFloatResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + if m.Value != 0 { + n += 9 + } + n += len(m.unknownFields) + return n +} + +func (m *GetBytesResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + l = len(m.Value) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *RemoveRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *RemoveResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Success { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func (m *HasRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Key) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *HasResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Exists { + n += 2 + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *SetStringRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetStringRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetStringRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetIntRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetIntRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetIntRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + m.Value = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Value |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetFloatRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetFloatRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetFloatRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 1 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var v uint64 + if (iNdEx + 8) > l { + return io.ErrUnexpectedEOF + } + v = uint64(binary.LittleEndian.Uint64(dAtA[iNdEx:])) + iNdEx += 8 + m.Value = float64(math.Float64frombits(v)) + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetBytesRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetBytesRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetBytesRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = append(m.Value[:0], dAtA[iNdEx:postIndex]...) + if m.Value == nil { + m.Value = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TtlSeconds", wireType) + } + m.TtlSeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TtlSeconds |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SetResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SetResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SetResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Success", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Success = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetStringResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetStringResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetStringResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetIntResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetIntResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetIntResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + m.Value = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Value |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetFloatResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetFloatResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetFloatResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 1 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var v uint64 + if (iNdEx + 8) > l { + return io.ErrUnexpectedEOF + } + v = uint64(binary.LittleEndian.Uint64(dAtA[iNdEx:])) + iNdEx += 8 + m.Value = float64(math.Float64frombits(v)) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetBytesResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetBytesResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetBytesResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Value", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Value = append(m.Value[:0], dAtA[iNdEx:postIndex]...) + if m.Value == nil { + m.Value = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *RemoveRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: RemoveRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: RemoveRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *RemoveResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: RemoveResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: RemoveResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Success", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Success = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *HasRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HasRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HasRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Key", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Key = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *HasResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HasResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HasResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Exists", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Exists = bool(v != 0) + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/config/config.pb.go b/plugins/host/config/config.pb.go new file mode 100644 index 0000000..dfc70af --- /dev/null +++ b/plugins/host/config/config.pb.go @@ -0,0 +1,54 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/config/config.proto + +package config + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetPluginConfigRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *GetPluginConfigRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type GetPluginConfigResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *GetPluginConfigResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *GetPluginConfigResponse) GetConfig() map[string]string { + if x != nil { + return x.Config + } + return nil +} + +// go:plugin type=host version=1 +type ConfigService interface { + GetPluginConfig(context.Context, *GetPluginConfigRequest) (*GetPluginConfigResponse, error) +} diff --git a/plugins/host/config/config.proto b/plugins/host/config/config.proto new file mode 100644 index 0000000..76076b4 --- /dev/null +++ b/plugins/host/config/config.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +package config; + +option go_package = "github.com/navidrome/navidrome/plugins/host/config;config"; + +// go:plugin type=host version=1 +service ConfigService { + rpc GetPluginConfig(GetPluginConfigRequest) returns (GetPluginConfigResponse); +} + +message GetPluginConfigRequest { + // No fields needed; plugin name is inferred from context +} + +message GetPluginConfigResponse { + map<string, string> config = 1; +} \ No newline at end of file diff --git a/plugins/host/config/config_host.pb.go b/plugins/host/config/config_host.pb.go new file mode 100644 index 0000000..87894f1 --- /dev/null +++ b/plugins/host/config/config_host.pb.go @@ -0,0 +1,66 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/config/config.proto + +package config + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _configService struct { + ConfigService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions ConfigService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _configService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._GetPluginConfig), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get_plugin_config") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _configService) _GetPluginConfig(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(GetPluginConfigRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.GetPluginConfig(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/config/config_plugin.pb.go b/plugins/host/config/config_plugin.pb.go new file mode 100644 index 0000000..45c60d1 --- /dev/null +++ b/plugins/host/config/config_plugin.pb.go @@ -0,0 +1,44 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/config/config.proto + +package config + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type configService struct{} + +func NewConfigService() ConfigService { + return configService{} +} + +//go:wasmimport env get_plugin_config +func _get_plugin_config(ptr uint32, size uint32) uint64 + +func (h configService) GetPluginConfig(ctx context.Context, request *GetPluginConfigRequest) (*GetPluginConfigResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get_plugin_config(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(GetPluginConfigResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/config/config_plugin_dev.go b/plugins/host/config/config_plugin_dev.go new file mode 100644 index 0000000..dddbc9c --- /dev/null +++ b/plugins/host/config/config_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package config + +func NewConfigService() ConfigService { + panic("not implemented") +} diff --git a/plugins/host/config/config_vtproto.pb.go b/plugins/host/config/config_vtproto.pb.go new file mode 100644 index 0000000..295da16 --- /dev/null +++ b/plugins/host/config/config_vtproto.pb.go @@ -0,0 +1,466 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/config/config.proto + +package config + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *GetPluginConfigRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetPluginConfigRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetPluginConfigRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *GetPluginConfigResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *GetPluginConfigResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *GetPluginConfigResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Config) > 0 { + for k := range m.Config { + v := m.Config[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0xa + } + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *GetPluginConfigRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *GetPluginConfigResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if len(m.Config) > 0 { + for k, v := range m.Config { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *GetPluginConfigRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetPluginConfigRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetPluginConfigRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *GetPluginConfigResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: GetPluginConfigResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: GetPluginConfigResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Config", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Config == nil { + m.Config = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Config[mapkey] = mapvalue + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/http/http.pb.go b/plugins/host/http/http.pb.go new file mode 100644 index 0000000..0bc2c50 --- /dev/null +++ b/plugins/host/http/http.pb.go @@ -0,0 +1,117 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/http/http.proto + +package http + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type HttpRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + TimeoutMs int32 `protobuf:"varint,3,opt,name=timeout_ms,json=timeoutMs,proto3" json:"timeout_ms,omitempty"` + Body []byte `protobuf:"bytes,4,opt,name=body,proto3" json:"body,omitempty"` // Ignored for GET/DELETE/HEAD/OPTIONS +} + +func (x *HttpRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HttpRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *HttpRequest) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *HttpRequest) GetTimeoutMs() int32 { + if x != nil { + return x.TimeoutMs + } + return 0 +} + +func (x *HttpRequest) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +type HttpResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Status int32 `protobuf:"varint,1,opt,name=status,proto3" json:"status,omitempty"` + Body []byte `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + Headers map[string]string `protobuf:"bytes,3,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if network/protocol error +} + +func (x *HttpResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *HttpResponse) GetStatus() int32 { + if x != nil { + return x.Status + } + return 0 +} + +func (x *HttpResponse) GetBody() []byte { + if x != nil { + return x.Body + } + return nil +} + +func (x *HttpResponse) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *HttpResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// go:plugin type=host version=1 +type HttpService interface { + Get(context.Context, *HttpRequest) (*HttpResponse, error) + Post(context.Context, *HttpRequest) (*HttpResponse, error) + Put(context.Context, *HttpRequest) (*HttpResponse, error) + Delete(context.Context, *HttpRequest) (*HttpResponse, error) + Patch(context.Context, *HttpRequest) (*HttpResponse, error) + Head(context.Context, *HttpRequest) (*HttpResponse, error) + Options(context.Context, *HttpRequest) (*HttpResponse, error) +} diff --git a/plugins/host/http/http.proto b/plugins/host/http/http.proto new file mode 100644 index 0000000..2ed7a42 --- /dev/null +++ b/plugins/host/http/http.proto @@ -0,0 +1,30 @@ +syntax = "proto3"; + +package http; + +option go_package = "github.com/navidrome/navidrome/plugins/host/http;http"; + +// go:plugin type=host version=1 +service HttpService { + rpc Get(HttpRequest) returns (HttpResponse); + rpc Post(HttpRequest) returns (HttpResponse); + rpc Put(HttpRequest) returns (HttpResponse); + rpc Delete(HttpRequest) returns (HttpResponse); + rpc Patch(HttpRequest) returns (HttpResponse); + rpc Head(HttpRequest) returns (HttpResponse); + rpc Options(HttpRequest) returns (HttpResponse); +} + +message HttpRequest { + string url = 1; + map<string, string> headers = 2; + int32 timeout_ms = 3; + bytes body = 4; // Ignored for GET/DELETE/HEAD/OPTIONS +} + +message HttpResponse { + int32 status = 1; + bytes body = 2; + map<string, string> headers = 3; + string error = 4; // Non-empty if network/protocol error +} \ No newline at end of file diff --git a/plugins/host/http/http_host.pb.go b/plugins/host/http/http_host.pb.go new file mode 100644 index 0000000..326aba5 --- /dev/null +++ b/plugins/host/http/http_host.pb.go @@ -0,0 +1,258 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/http/http.proto + +package http + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _httpService struct { + HttpService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions HttpService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _httpService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Get), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("get") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Post), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("post") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Put), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("put") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Delete), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("delete") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Patch), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("patch") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Head), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("head") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Options), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("options") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _httpService) _Get(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Get(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Post(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Post(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Put(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Put(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Delete(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Delete(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Patch(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Patch(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Head(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Head(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +func (h _httpService) _Options(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(HttpRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Options(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/http/http_plugin.pb.go b/plugins/host/http/http_plugin.pb.go new file mode 100644 index 0000000..2e8c218 --- /dev/null +++ b/plugins/host/http/http_plugin.pb.go @@ -0,0 +1,182 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/http/http.proto + +package http + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type httpService struct{} + +func NewHttpService() HttpService { + return httpService{} +} + +//go:wasmimport env get +func _get(ptr uint32, size uint32) uint64 + +func (h httpService) Get(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _get(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env post +func _post(ptr uint32, size uint32) uint64 + +func (h httpService) Post(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _post(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env put +func _put(ptr uint32, size uint32) uint64 + +func (h httpService) Put(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _put(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env delete +func _delete(ptr uint32, size uint32) uint64 + +func (h httpService) Delete(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _delete(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env patch +func _patch(ptr uint32, size uint32) uint64 + +func (h httpService) Patch(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _patch(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env head +func _head(ptr uint32, size uint32) uint64 + +func (h httpService) Head(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _head(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env options +func _options(ptr uint32, size uint32) uint64 + +func (h httpService) Options(ctx context.Context, request *HttpRequest) (*HttpResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _options(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(HttpResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/http/http_plugin_dev.go b/plugins/host/http/http_plugin_dev.go new file mode 100644 index 0000000..04e3c25 --- /dev/null +++ b/plugins/host/http/http_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package http + +func NewHttpService() HttpService { + panic("not implemented") +} diff --git a/plugins/host/http/http_vtproto.pb.go b/plugins/host/http/http_vtproto.pb.go new file mode 100644 index 0000000..064fdb0 --- /dev/null +++ b/plugins/host/http/http_vtproto.pb.go @@ -0,0 +1,850 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/http/http.proto + +package http + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *HttpRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HttpRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HttpRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Body) > 0 { + i -= len(m.Body) + copy(dAtA[i:], m.Body) + i = encodeVarint(dAtA, i, uint64(len(m.Body))) + i-- + dAtA[i] = 0x22 + } + if m.TimeoutMs != 0 { + i = encodeVarint(dAtA, i, uint64(m.TimeoutMs)) + i-- + dAtA[i] = 0x18 + } + if len(m.Headers) > 0 { + for k := range m.Headers { + v := m.Headers[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0x12 + } + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *HttpResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *HttpResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *HttpResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x22 + } + if len(m.Headers) > 0 { + for k := range m.Headers { + v := m.Headers[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0x1a + } + } + if len(m.Body) > 0 { + i -= len(m.Body) + copy(dAtA[i:], m.Body) + i = encodeVarint(dAtA, i, uint64(len(m.Body))) + i-- + dAtA[i] = 0x12 + } + if m.Status != 0 { + i = encodeVarint(dAtA, i, uint64(m.Status)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *HttpRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Headers) > 0 { + for k, v := range m.Headers { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + if m.TimeoutMs != 0 { + n += 1 + sov(uint64(m.TimeoutMs)) + } + l = len(m.Body) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *HttpResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Status != 0 { + n += 1 + sov(uint64(m.Status)) + } + l = len(m.Body) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Headers) > 0 { + for k, v := range m.Headers { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *HttpRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HttpRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HttpRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Headers == nil { + m.Headers = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Headers[mapkey] = mapvalue + iNdEx = postIndex + case 3: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field TimeoutMs", wireType) + } + m.TimeoutMs = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.TimeoutMs |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...) + if m.Body == nil { + m.Body = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *HttpResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: HttpResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: HttpResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Status", wireType) + } + m.Status = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Status |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Body", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Body = append(m.Body[:0], dAtA[iNdEx:postIndex]...) + if m.Body == nil { + m.Body = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Headers == nil { + m.Headers = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Headers[mapkey] = mapvalue + iNdEx = postIndex + case 4: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/scheduler/scheduler.pb.go b/plugins/host/scheduler/scheduler.pb.go new file mode 100644 index 0000000..07d250c --- /dev/null +++ b/plugins/host/scheduler/scheduler.pb.go @@ -0,0 +1,212 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/scheduler/scheduler.proto + +package scheduler + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ScheduleOneTimeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + DelaySeconds int32 `protobuf:"varint,1,opt,name=delay_seconds,json=delaySeconds,proto3" json:"delay_seconds,omitempty"` // Delay in seconds + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback + ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated) +} + +func (x *ScheduleOneTimeRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScheduleOneTimeRequest) GetDelaySeconds() int32 { + if x != nil { + return x.DelaySeconds + } + return 0 +} + +func (x *ScheduleOneTimeRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ScheduleOneTimeRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type ScheduleRecurringRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + CronExpression string `protobuf:"bytes,1,opt,name=cron_expression,json=cronExpression,proto3" json:"cron_expression,omitempty"` // Cron expression (e.g. "0 0 * * *" for daily at midnight) + Payload []byte `protobuf:"bytes,2,opt,name=payload,proto3" json:"payload,omitempty"` // Serialized data to pass to the callback + ScheduleId string `protobuf:"bytes,3,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // Optional custom ID (if not provided, one will be generated) +} + +func (x *ScheduleRecurringRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScheduleRecurringRequest) GetCronExpression() string { + if x != nil { + return x.CronExpression + } + return "" +} + +func (x *ScheduleRecurringRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *ScheduleRecurringRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type ScheduleResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID to reference this scheduled job +} + +func (x *ScheduleResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ScheduleResponse) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type CancelRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ScheduleId string `protobuf:"bytes,1,opt,name=schedule_id,json=scheduleId,proto3" json:"schedule_id,omitempty"` // ID of the schedule to cancel +} + +func (x *CancelRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CancelRequest) GetScheduleId() string { + if x != nil { + return x.ScheduleId + } + return "" +} + +type CancelResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` // Whether cancellation was successful + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Error message if cancellation failed +} + +func (x *CancelResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CancelResponse) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *CancelResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type TimeNowRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *TimeNowRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type TimeNowResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Rfc3339Nano string `protobuf:"bytes,1,opt,name=rfc3339_nano,json=rfc3339Nano,proto3" json:"rfc3339_nano,omitempty"` // Current time in RFC3339Nano format + UnixMilli int64 `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"` // Current time as Unix milliseconds timestamp + LocalTimeZone string `protobuf:"bytes,3,opt,name=local_time_zone,json=localTimeZone,proto3" json:"local_time_zone,omitempty"` // Local timezone name (e.g., "America/New_York", "UTC") +} + +func (x *TimeNowResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *TimeNowResponse) GetRfc3339Nano() string { + if x != nil { + return x.Rfc3339Nano + } + return "" +} + +func (x *TimeNowResponse) GetUnixMilli() int64 { + if x != nil { + return x.UnixMilli + } + return 0 +} + +func (x *TimeNowResponse) GetLocalTimeZone() string { + if x != nil { + return x.LocalTimeZone + } + return "" +} + +// go:plugin type=host version=1 +type SchedulerService interface { + // One-time event scheduling + ScheduleOneTime(context.Context, *ScheduleOneTimeRequest) (*ScheduleResponse, error) + // Recurring event scheduling + ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error) + // Cancel any scheduled job + CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error) + // Get current time in multiple formats + TimeNow(context.Context, *TimeNowRequest) (*TimeNowResponse, error) +} diff --git a/plugins/host/scheduler/scheduler.proto b/plugins/host/scheduler/scheduler.proto new file mode 100644 index 0000000..d164b4f --- /dev/null +++ b/plugins/host/scheduler/scheduler.proto @@ -0,0 +1,55 @@ +syntax = "proto3"; + +package scheduler; + +option go_package = "github.com/navidrome/navidrome/plugins/host/scheduler;scheduler"; + +// go:plugin type=host version=1 +service SchedulerService { + // One-time event scheduling + rpc ScheduleOneTime(ScheduleOneTimeRequest) returns (ScheduleResponse); + + // Recurring event scheduling + rpc ScheduleRecurring(ScheduleRecurringRequest) returns (ScheduleResponse); + + // Cancel any scheduled job + rpc CancelSchedule(CancelRequest) returns (CancelResponse); + + // Get current time in multiple formats + rpc TimeNow(TimeNowRequest) returns (TimeNowResponse); +} + +message ScheduleOneTimeRequest { + int32 delay_seconds = 1; // Delay in seconds + bytes payload = 2; // Serialized data to pass to the callback + string schedule_id = 3; // Optional custom ID (if not provided, one will be generated) +} + +message ScheduleRecurringRequest { + string cron_expression = 1; // Cron expression (e.g. "0 0 * * *" for daily at midnight) + bytes payload = 2; // Serialized data to pass to the callback + string schedule_id = 3; // Optional custom ID (if not provided, one will be generated) +} + +message ScheduleResponse { + string schedule_id = 1; // ID to reference this scheduled job +} + +message CancelRequest { + string schedule_id = 1; // ID of the schedule to cancel +} + +message CancelResponse { + bool success = 1; // Whether cancellation was successful + string error = 2; // Error message if cancellation failed +} + +message TimeNowRequest { + // Empty request - no parameters needed +} + +message TimeNowResponse { + string rfc3339_nano = 1; // Current time in RFC3339Nano format + int64 unix_milli = 2; // Current time as Unix milliseconds timestamp + string local_time_zone = 3; // Local timezone name (e.g., "America/New_York", "UTC") +} \ No newline at end of file diff --git a/plugins/host/scheduler/scheduler_host.pb.go b/plugins/host/scheduler/scheduler_host.pb.go new file mode 100644 index 0000000..714603a --- /dev/null +++ b/plugins/host/scheduler/scheduler_host.pb.go @@ -0,0 +1,170 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/scheduler/scheduler.proto + +package scheduler + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _schedulerService struct { + SchedulerService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _schedulerService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._ScheduleOneTime), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("schedule_one_time") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._ScheduleRecurring), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("schedule_recurring") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._CancelSchedule), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("cancel_schedule") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._TimeNow), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("time_now") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +// One-time event scheduling + +func (h _schedulerService) _ScheduleOneTime(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(ScheduleOneTimeRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.ScheduleOneTime(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Recurring event scheduling + +func (h _schedulerService) _ScheduleRecurring(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(ScheduleRecurringRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.ScheduleRecurring(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Cancel any scheduled job + +func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(CancelRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.CancelSchedule(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Get current time in multiple formats + +func (h _schedulerService) _TimeNow(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(TimeNowRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.TimeNow(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/scheduler/scheduler_plugin.pb.go b/plugins/host/scheduler/scheduler_plugin.pb.go new file mode 100644 index 0000000..ab7f8cd --- /dev/null +++ b/plugins/host/scheduler/scheduler_plugin.pb.go @@ -0,0 +1,113 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/scheduler/scheduler.proto + +package scheduler + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type schedulerService struct{} + +func NewSchedulerService() SchedulerService { + return schedulerService{} +} + +//go:wasmimport env schedule_one_time +func _schedule_one_time(ptr uint32, size uint32) uint64 + +func (h schedulerService) ScheduleOneTime(ctx context.Context, request *ScheduleOneTimeRequest) (*ScheduleResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _schedule_one_time(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(ScheduleResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env schedule_recurring +func _schedule_recurring(ptr uint32, size uint32) uint64 + +func (h schedulerService) ScheduleRecurring(ctx context.Context, request *ScheduleRecurringRequest) (*ScheduleResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _schedule_recurring(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(ScheduleResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env cancel_schedule +func _cancel_schedule(ptr uint32, size uint32) uint64 + +func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelRequest) (*CancelResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _cancel_schedule(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(CancelResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env time_now +func _time_now(ptr uint32, size uint32) uint64 + +func (h schedulerService) TimeNow(ctx context.Context, request *TimeNowRequest) (*TimeNowResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _time_now(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(TimeNowResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/scheduler/scheduler_plugin_dev.go b/plugins/host/scheduler/scheduler_plugin_dev.go new file mode 100644 index 0000000..b6feaa8 --- /dev/null +++ b/plugins/host/scheduler/scheduler_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package scheduler + +func NewSchedulerService() SchedulerService { + panic("not implemented") +} diff --git a/plugins/host/scheduler/scheduler_vtproto.pb.go b/plugins/host/scheduler/scheduler_vtproto.pb.go new file mode 100644 index 0000000..ee64217 --- /dev/null +++ b/plugins/host/scheduler/scheduler_vtproto.pb.go @@ -0,0 +1,1303 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/scheduler/scheduler.proto + +package scheduler + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *ScheduleOneTimeRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScheduleOneTimeRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScheduleOneTimeRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0x1a + } + if len(m.Payload) > 0 { + i -= len(m.Payload) + copy(dAtA[i:], m.Payload) + i = encodeVarint(dAtA, i, uint64(len(m.Payload))) + i-- + dAtA[i] = 0x12 + } + if m.DelaySeconds != 0 { + i = encodeVarint(dAtA, i, uint64(m.DelaySeconds)) + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *ScheduleRecurringRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScheduleRecurringRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScheduleRecurringRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0x1a + } + if len(m.Payload) > 0 { + i -= len(m.Payload) + copy(dAtA[i:], m.Payload) + i = encodeVarint(dAtA, i, uint64(len(m.Payload))) + i-- + dAtA[i] = 0x12 + } + if len(m.CronExpression) > 0 { + i -= len(m.CronExpression) + copy(dAtA[i:], m.CronExpression) + i = encodeVarint(dAtA, i, uint64(len(m.CronExpression))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ScheduleResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ScheduleResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ScheduleResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CancelRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CancelRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CancelRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ScheduleId) > 0 { + i -= len(m.ScheduleId) + copy(dAtA[i:], m.ScheduleId) + i = encodeVarint(dAtA, i, uint64(len(m.ScheduleId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CancelResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CancelResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CancelResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if m.Success { + i-- + if m.Success { + dAtA[i] = 1 + } else { + dAtA[i] = 0 + } + i-- + dAtA[i] = 0x8 + } + return len(dAtA) - i, nil +} + +func (m *TimeNowRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TimeNowRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TimeNowRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *TimeNowResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TimeNowResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TimeNowResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.LocalTimeZone) > 0 { + i -= len(m.LocalTimeZone) + copy(dAtA[i:], m.LocalTimeZone) + i = encodeVarint(dAtA, i, uint64(len(m.LocalTimeZone))) + i-- + dAtA[i] = 0x1a + } + if m.UnixMilli != 0 { + i = encodeVarint(dAtA, i, uint64(m.UnixMilli)) + i-- + dAtA[i] = 0x10 + } + if len(m.Rfc3339Nano) > 0 { + i -= len(m.Rfc3339Nano) + copy(dAtA[i:], m.Rfc3339Nano) + i = encodeVarint(dAtA, i, uint64(len(m.Rfc3339Nano))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *ScheduleOneTimeRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.DelaySeconds != 0 { + n += 1 + sov(uint64(m.DelaySeconds)) + } + l = len(m.Payload) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScheduleRecurringRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.CronExpression) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Payload) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ScheduleResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CancelRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ScheduleId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CancelResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + if m.Success { + n += 2 + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *TimeNowRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *TimeNowResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Rfc3339Nano) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.UnixMilli != 0 { + n += 1 + sov(uint64(m.UnixMilli)) + } + l = len(m.LocalTimeZone) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *ScheduleOneTimeRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScheduleOneTimeRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScheduleOneTimeRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field DelaySeconds", wireType) + } + m.DelaySeconds = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.DelaySeconds |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...) + if m.Payload == nil { + m.Payload = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScheduleRecurringRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScheduleRecurringRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScheduleRecurringRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field CronExpression", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.CronExpression = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Payload", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Payload = append(m.Payload[:0], dAtA[iNdEx:postIndex]...) + if m.Payload == nil { + m.Payload = []byte{} + } + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ScheduleResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ScheduleResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ScheduleResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CancelRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CancelRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CancelRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ScheduleId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ScheduleId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CancelResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CancelResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CancelResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Success", wireType) + } + var v int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + v |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + m.Success = bool(v != 0) + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TimeNowRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TimeNowRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TimeNowRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TimeNowResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TimeNowResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TimeNowResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Rfc3339Nano", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Rfc3339Nano = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field UnixMilli", wireType) + } + m.UnixMilli = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.UnixMilli |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field LocalTimeZone", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.LocalTimeZone = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/subsonicapi/subsonicapi.pb.go b/plugins/host/subsonicapi/subsonicapi.pb.go new file mode 100644 index 0000000..0dbd905 --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi.pb.go @@ -0,0 +1,71 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type CallRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *CallRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CallRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type CallResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Json string `protobuf:"bytes,1,opt,name=json,proto3" json:"json,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` // Non-empty if operation failed +} + +func (x *CallResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CallResponse) GetJson() string { + if x != nil { + return x.Json + } + return "" +} + +func (x *CallResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// go:plugin type=host version=1 +type SubsonicAPIService interface { + Call(context.Context, *CallRequest) (*CallResponse, error) +} diff --git a/plugins/host/subsonicapi/subsonicapi.proto b/plugins/host/subsonicapi/subsonicapi.proto new file mode 100644 index 0000000..29dc365 --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; + +package subsonicapi; + +option go_package = "github.com/navidrome/navidrome/plugins/host/subsonicapi;subsonicapi"; + +// go:plugin type=host version=1 +service SubsonicAPIService { + rpc Call(CallRequest) returns (CallResponse); +} + +message CallRequest { + string url = 1; +} + +message CallResponse { + string json = 1; + string error = 2; // Non-empty if operation failed +} \ No newline at end of file diff --git a/plugins/host/subsonicapi/subsonicapi_host.pb.go b/plugins/host/subsonicapi/subsonicapi_host.pb.go new file mode 100644 index 0000000..b7c0f04 --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi_host.pb.go @@ -0,0 +1,66 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _subsonicAPIService struct { + SubsonicAPIService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SubsonicAPIService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _subsonicAPIService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Call), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("call") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +func (h _subsonicAPIService) _Call(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(CallRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Call(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/subsonicapi/subsonicapi_plugin.pb.go b/plugins/host/subsonicapi/subsonicapi_plugin.pb.go new file mode 100644 index 0000000..1ffdbf5 --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi_plugin.pb.go @@ -0,0 +1,44 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type subsonicAPIService struct{} + +func NewSubsonicAPIService() SubsonicAPIService { + return subsonicAPIService{} +} + +//go:wasmimport env call +func _call(ptr uint32, size uint32) uint64 + +func (h subsonicAPIService) Call(ctx context.Context, request *CallRequest) (*CallResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _call(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(CallResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/subsonicapi/subsonicapi_vtproto.pb.go b/plugins/host/subsonicapi/subsonicapi_vtproto.pb.go new file mode 100644 index 0000000..0540321 --- /dev/null +++ b/plugins/host/subsonicapi/subsonicapi_vtproto.pb.go @@ -0,0 +1,441 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/subsonicapi/subsonicapi.proto + +package subsonicapi + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *CallRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CallRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CallRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CallResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CallResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CallResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if len(m.Json) > 0 { + i -= len(m.Json) + copy(dAtA[i:], m.Json) + i = encodeVarint(dAtA, i, uint64(len(m.Json))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *CallRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CallResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Json) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *CallRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CallRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CallRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CallResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CallResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CallResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Json", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Json = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host/websocket/websocket.pb.go b/plugins/host/websocket/websocket.pb.go new file mode 100644 index 0000000..f3ab689 --- /dev/null +++ b/plugins/host/websocket/websocket.pb.go @@ -0,0 +1,240 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/websocket/websocket.proto + +package websocket + +import ( + context "context" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ConnectRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Headers map[string]string `protobuf:"bytes,2,rep,name=headers,proto3" json:"headers,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + ConnectionId string `protobuf:"bytes,3,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` +} + +func (x *ConnectRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ConnectRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ConnectRequest) GetHeaders() map[string]string { + if x != nil { + return x.Headers + } + return nil +} + +func (x *ConnectRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +type ConnectResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *ConnectResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *ConnectResponse) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *ConnectResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SendTextRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` +} + +func (x *SendTextRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendTextRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *SendTextRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +type SendTextResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *SendTextResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendTextResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type SendBinaryRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Data []byte `protobuf:"bytes,2,opt,name=data,proto3" json:"data,omitempty"` +} + +func (x *SendBinaryRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendBinaryRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *SendBinaryRequest) GetData() []byte { + if x != nil { + return x.Data + } + return nil +} + +type SendBinaryResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *SendBinaryResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *SendBinaryResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type CloseRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + Code int32 `protobuf:"varint,2,opt,name=code,proto3" json:"code,omitempty"` + Reason string `protobuf:"bytes,3,opt,name=reason,proto3" json:"reason,omitempty"` +} + +func (x *CloseRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CloseRequest) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *CloseRequest) GetCode() int32 { + if x != nil { + return x.Code + } + return 0 +} + +func (x *CloseRequest) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +type CloseResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Error string `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` +} + +func (x *CloseResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *CloseResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// go:plugin type=host version=1 +type WebSocketService interface { + // Connect to a WebSocket endpoint + Connect(context.Context, *ConnectRequest) (*ConnectResponse, error) + // Send a text message + SendText(context.Context, *SendTextRequest) (*SendTextResponse, error) + // Send binary data + SendBinary(context.Context, *SendBinaryRequest) (*SendBinaryResponse, error) + // Close a connection + Close(context.Context, *CloseRequest) (*CloseResponse, error) +} diff --git a/plugins/host/websocket/websocket.proto b/plugins/host/websocket/websocket.proto new file mode 100644 index 0000000..53adaca --- /dev/null +++ b/plugins/host/websocket/websocket.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; +package websocket; +option go_package = "github.com/navidrome/navidrome/plugins/host/websocket"; + +// go:plugin type=host version=1 +service WebSocketService { + // Connect to a WebSocket endpoint + rpc Connect(ConnectRequest) returns (ConnectResponse); + + // Send a text message + rpc SendText(SendTextRequest) returns (SendTextResponse); + + // Send binary data + rpc SendBinary(SendBinaryRequest) returns (SendBinaryResponse); + + // Close a connection + rpc Close(CloseRequest) returns (CloseResponse); +} + +message ConnectRequest { + string url = 1; + map<string, string> headers = 2; + string connection_id = 3; +} + +message ConnectResponse { + string connection_id = 1; + string error = 2; +} + +message SendTextRequest { + string connection_id = 1; + string message = 2; +} + +message SendTextResponse { + string error = 1; +} + +message SendBinaryRequest { + string connection_id = 1; + bytes data = 2; +} + +message SendBinaryResponse { + string error = 1; +} + +message CloseRequest { + string connection_id = 1; + int32 code = 2; + string reason = 3; +} + +message CloseResponse { + string error = 1; +} \ No newline at end of file diff --git a/plugins/host/websocket/websocket_host.pb.go b/plugins/host/websocket/websocket_host.pb.go new file mode 100644 index 0000000..b95eb45 --- /dev/null +++ b/plugins/host/websocket/websocket_host.pb.go @@ -0,0 +1,170 @@ +//go:build !wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/websocket/websocket.proto + +package websocket + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + wazero "github.com/tetratelabs/wazero" + api "github.com/tetratelabs/wazero/api" +) + +const ( + i32 = api.ValueTypeI32 + i64 = api.ValueTypeI64 +) + +type _webSocketService struct { + WebSocketService +} + +// Instantiate a Go-defined module named "env" that exports host functions. +func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions WebSocketService) error { + envBuilder := r.NewHostModuleBuilder("env") + h := _webSocketService{hostFunctions} + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Connect), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("connect") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SendText), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("send_text") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._SendBinary), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("send_binary") + + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._Close), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("close") + + _, err := envBuilder.Instantiate(ctx) + return err +} + +// Connect to a WebSocket endpoint + +func (h _webSocketService) _Connect(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(ConnectRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Connect(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Send a text message + +func (h _webSocketService) _SendText(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SendTextRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SendText(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Send binary data + +func (h _webSocketService) _SendBinary(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(SendBinaryRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.SendBinary(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} + +// Close a connection + +func (h _webSocketService) _Close(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(CloseRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.Close(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/websocket/websocket_plugin.pb.go b/plugins/host/websocket/websocket_plugin.pb.go new file mode 100644 index 0000000..e7d5c3f --- /dev/null +++ b/plugins/host/websocket/websocket_plugin.pb.go @@ -0,0 +1,113 @@ +//go:build wasip1 + +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/websocket/websocket.proto + +package websocket + +import ( + context "context" + wasm "github.com/knqyf263/go-plugin/wasm" + _ "unsafe" +) + +type webSocketService struct{} + +func NewWebSocketService() WebSocketService { + return webSocketService{} +} + +//go:wasmimport env connect +func _connect(ptr uint32, size uint32) uint64 + +func (h webSocketService) Connect(ctx context.Context, request *ConnectRequest) (*ConnectResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _connect(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(ConnectResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env send_text +func _send_text(ptr uint32, size uint32) uint64 + +func (h webSocketService) SendText(ctx context.Context, request *SendTextRequest) (*SendTextResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _send_text(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SendTextResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env send_binary +func _send_binary(ptr uint32, size uint32) uint64 + +func (h webSocketService) SendBinary(ctx context.Context, request *SendBinaryRequest) (*SendBinaryResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _send_binary(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(SendBinaryResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} + +//go:wasmimport env close +func _close(ptr uint32, size uint32) uint64 + +func (h webSocketService) Close(ctx context.Context, request *CloseRequest) (*CloseResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _close(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(CloseResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/websocket/websocket_plugin_dev.go b/plugins/host/websocket/websocket_plugin_dev.go new file mode 100644 index 0000000..cfb7246 --- /dev/null +++ b/plugins/host/websocket/websocket_plugin_dev.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package websocket + +func NewWebSocketService() WebSocketService { + panic("not implemented") +} diff --git a/plugins/host/websocket/websocket_vtproto.pb.go b/plugins/host/websocket/websocket_vtproto.pb.go new file mode 100644 index 0000000..fb15a22 --- /dev/null +++ b/plugins/host/websocket/websocket_vtproto.pb.go @@ -0,0 +1,1618 @@ +// Code generated by protoc-gen-go-plugin. DO NOT EDIT. +// versions: +// protoc-gen-go-plugin v0.1.0 +// protoc v5.29.3 +// source: host/websocket/websocket.proto + +package websocket + +import ( + fmt "fmt" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + io "io" + bits "math/bits" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +func (m *ConnectRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ConnectRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ConnectRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0x1a + } + if len(m.Headers) > 0 { + for k := range m.Headers { + v := m.Headers[k] + baseI := i + i -= len(v) + copy(dAtA[i:], v) + i = encodeVarint(dAtA, i, uint64(len(v))) + i-- + dAtA[i] = 0x12 + i -= len(k) + copy(dAtA[i:], k) + i = encodeVarint(dAtA, i, uint64(len(k))) + i-- + dAtA[i] = 0xa + i = encodeVarint(dAtA, i, uint64(baseI-i)) + i-- + dAtA[i] = 0x12 + } + } + if len(m.Url) > 0 { + i -= len(m.Url) + copy(dAtA[i:], m.Url) + i = encodeVarint(dAtA, i, uint64(len(m.Url))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *ConnectResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *ConnectResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *ConnectResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendTextRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendTextRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendTextRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Message) > 0 { + i -= len(m.Message) + copy(dAtA[i:], m.Message) + i = encodeVarint(dAtA, i, uint64(len(m.Message))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendTextResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendTextResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendTextResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendBinaryRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendBinaryRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendBinaryRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Data) > 0 { + i -= len(m.Data) + copy(dAtA[i:], m.Data) + i = encodeVarint(dAtA, i, uint64(len(m.Data))) + i-- + dAtA[i] = 0x12 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *SendBinaryResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *SendBinaryResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *SendBinaryResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CloseRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CloseRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CloseRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Reason) > 0 { + i -= len(m.Reason) + copy(dAtA[i:], m.Reason) + i = encodeVarint(dAtA, i, uint64(len(m.Reason))) + i-- + dAtA[i] = 0x1a + } + if m.Code != 0 { + i = encodeVarint(dAtA, i, uint64(m.Code)) + i-- + dAtA[i] = 0x10 + } + if len(m.ConnectionId) > 0 { + i -= len(m.ConnectionId) + copy(dAtA[i:], m.ConnectionId) + i = encodeVarint(dAtA, i, uint64(len(m.ConnectionId))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func (m *CloseResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *CloseResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *CloseResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.Error) > 0 { + i -= len(m.Error) + copy(dAtA[i:], m.Error) + i = encodeVarint(dAtA, i, uint64(len(m.Error))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + +func encodeVarint(dAtA []byte, offset int, v uint64) int { + offset -= sov(v) + base := offset + for v >= 1<<7 { + dAtA[offset] = uint8(v&0x7f | 0x80) + v >>= 7 + offset++ + } + dAtA[offset] = uint8(v) + return base +} +func (m *ConnectRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Url) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if len(m.Headers) > 0 { + for k, v := range m.Headers { + _ = k + _ = v + mapEntrySize := 1 + len(k) + sov(uint64(len(k))) + 1 + len(v) + sov(uint64(len(v))) + n += mapEntrySize + 1 + sov(uint64(mapEntrySize)) + } + } + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *ConnectResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendTextRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Message) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendTextResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendBinaryRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + l = len(m.Data) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *SendBinaryResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CloseRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.ConnectionId) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.Code != 0 { + n += 1 + sov(uint64(m.Code)) + } + l = len(m.Reason) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func (m *CloseResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Error) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + +func sov(x uint64) (n int) { + return (bits.Len64(x|1) + 6) / 7 +} +func soz(x uint64) (n int) { + return sov(uint64((x << 1) ^ uint64((int64(x) >> 63)))) +} +func (m *ConnectRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ConnectRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ConnectRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Url", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Url = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Headers", wireType) + } + var msglen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + msglen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if msglen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + msglen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + if m.Headers == nil { + m.Headers = make(map[string]string) + } + var mapkey string + var mapvalue string + for iNdEx < postIndex { + entryPreIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + if fieldNum == 1 { + var stringLenmapkey uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapkey |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapkey := int(stringLenmapkey) + if intStringLenmapkey < 0 { + return ErrInvalidLength + } + postStringIndexmapkey := iNdEx + intStringLenmapkey + if postStringIndexmapkey < 0 { + return ErrInvalidLength + } + if postStringIndexmapkey > l { + return io.ErrUnexpectedEOF + } + mapkey = string(dAtA[iNdEx:postStringIndexmapkey]) + iNdEx = postStringIndexmapkey + } else if fieldNum == 2 { + var stringLenmapvalue uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLenmapvalue |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLenmapvalue := int(stringLenmapvalue) + if intStringLenmapvalue < 0 { + return ErrInvalidLength + } + postStringIndexmapvalue := iNdEx + intStringLenmapvalue + if postStringIndexmapvalue < 0 { + return ErrInvalidLength + } + if postStringIndexmapvalue > l { + return io.ErrUnexpectedEOF + } + mapvalue = string(dAtA[iNdEx:postStringIndexmapvalue]) + iNdEx = postStringIndexmapvalue + } else { + iNdEx = entryPreIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > postIndex { + return io.ErrUnexpectedEOF + } + iNdEx += skippy + } + } + m.Headers[mapkey] = mapvalue + iNdEx = postIndex + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *ConnectResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: ConnectResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: ConnectResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendTextRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendTextRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendTextRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Message", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Message = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendTextResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendTextResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendTextResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendBinaryRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendBinaryRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendBinaryRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Data", wireType) + } + var byteLen int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + byteLen |= int(b&0x7F) << shift + if b < 0x80 { + break + } + } + if byteLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + byteLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Data = append(m.Data[:0], dAtA[iNdEx:postIndex]...) + if m.Data == nil { + m.Data = []byte{} + } + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *SendBinaryResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: SendBinaryResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: SendBinaryResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CloseRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CloseRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CloseRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field ConnectionId", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.ConnectionId = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field Code", wireType) + } + m.Code = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.Code |= int32(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Reason", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Reason = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *CloseResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: CloseResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: CloseResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Error", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Error = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} + +func skip(dAtA []byte) (n int, err error) { + l := len(dAtA) + iNdEx := 0 + depth := 0 + for iNdEx < l { + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= (uint64(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + wireType := int(wire & 0x7) + switch wireType { + case 0: + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + iNdEx++ + if dAtA[iNdEx-1] < 0x80 { + break + } + } + case 1: + iNdEx += 8 + case 2: + var length int + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return 0, ErrIntOverflow + } + if iNdEx >= l { + return 0, io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + length |= (int(b) & 0x7F) << shift + if b < 0x80 { + break + } + } + if length < 0 { + return 0, ErrInvalidLength + } + iNdEx += length + case 3: + depth++ + case 4: + if depth == 0 { + return 0, ErrUnexpectedEndOfGroup + } + depth-- + case 5: + iNdEx += 4 + default: + return 0, fmt.Errorf("proto: illegal wireType %d", wireType) + } + if iNdEx < 0 { + return 0, ErrInvalidLength + } + if depth == 0 { + return iNdEx, nil + } + } + return 0, io.ErrUnexpectedEOF +} + +var ( + ErrInvalidLength = fmt.Errorf("proto: negative length found during unmarshaling") + ErrIntOverflow = fmt.Errorf("proto: integer overflow") + ErrUnexpectedEndOfGroup = fmt.Errorf("proto: unexpected end of group") +) diff --git a/plugins/host_artwork.go b/plugins/host_artwork.go new file mode 100644 index 0000000..dac6222 --- /dev/null +++ b/plugins/host_artwork.go @@ -0,0 +1,47 @@ +package plugins + +import ( + "context" + "fmt" + "net/http" + "net/url" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/host/artwork" + "github.com/navidrome/navidrome/server/public" +) + +type artworkServiceImpl struct{} + +func (a *artworkServiceImpl) GetArtistUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) { + artID := model.ArtworkID{Kind: model.KindArtistArtwork, ID: req.Id} + imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size)) + return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil +} + +func (a *artworkServiceImpl) GetAlbumUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) { + artID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: req.Id} + imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size)) + return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil +} + +func (a *artworkServiceImpl) GetTrackUrl(_ context.Context, req *artwork.GetArtworkUrlRequest) (*artwork.GetArtworkUrlResponse, error) { + artID := model.ArtworkID{Kind: model.KindMediaFileArtwork, ID: req.Id} + imageURL := public.ImageURL(a.createRequest(), artID, int(req.Size)) + return &artwork.GetArtworkUrlResponse{Url: imageURL}, nil +} + +func (a *artworkServiceImpl) createRequest() *http.Request { + var scheme, host string + if conf.Server.ShareURL != "" { + shareURL, _ := url.Parse(conf.Server.ShareURL) + scheme = shareURL.Scheme + host = shareURL.Host + } else { + scheme = "http" + host = "localhost" + } + r, _ := http.NewRequest("GET", fmt.Sprintf("%s://%s", scheme, host), nil) + return r +} diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go new file mode 100644 index 0000000..b6667bd --- /dev/null +++ b/plugins/host_artwork_test.go @@ -0,0 +1,58 @@ +package plugins + +import ( + "context" + + "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/plugins/host/artwork" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ArtworkService", func() { + var svc *artworkServiceImpl + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + // Setup auth for tests + auth.TokenAuth = jwtauth.New("HS256", []byte("super secret"), nil) + svc = &artworkServiceImpl{} + }) + + Context("with ShareURL configured", func() { + BeforeEach(func() { + conf.Server.ShareURL = "https://music.example.com" + }) + + It("returns artist artwork URL", func() { + resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123", Size: 300}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("https://music.example.com")) + Expect(resp.Url).To(ContainSubstring("size=300")) + }) + + It("returns album artwork URL", func() { + resp, err := svc.GetAlbumUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "456"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("https://music.example.com")) + }) + + It("returns track artwork URL", func() { + resp, err := svc.GetTrackUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "789", Size: 150}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("https://music.example.com")) + Expect(resp.Url).To(ContainSubstring("size=150")) + }) + }) + + Context("without ShareURL configured", func() { + It("returns localhost URLs", func() { + resp, err := svc.GetArtistUrl(context.Background(), &artwork.GetArtworkUrlRequest{Id: "123"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Url).To(ContainSubstring("http://localhost")) + }) + }) +}) diff --git a/plugins/host_cache.go b/plugins/host_cache.go new file mode 100644 index 0000000..291a178 --- /dev/null +++ b/plugins/host_cache.go @@ -0,0 +1,152 @@ +package plugins + +import ( + "context" + "sync" + "time" + + "github.com/jellydator/ttlcache/v3" + "github.com/navidrome/navidrome/log" + cacheproto "github.com/navidrome/navidrome/plugins/host/cache" +) + +const ( + defaultCacheTTL = 24 * time.Hour +) + +// cacheServiceImpl implements the cache.CacheService interface +type cacheServiceImpl struct { + pluginID string + defaultTTL time.Duration +} + +var ( + _cache *ttlcache.Cache[string, any] + initCacheOnce sync.Once +) + +// newCacheService creates a new cacheServiceImpl instance +func newCacheService(pluginID string) *cacheServiceImpl { + initCacheOnce.Do(func() { + opts := []ttlcache.Option[string, any]{ + ttlcache.WithTTL[string, any](defaultCacheTTL), + } + _cache = ttlcache.New[string, any](opts...) + + // Start the janitor goroutine to clean up expired entries + go _cache.Start() + }) + + return &cacheServiceImpl{ + pluginID: pluginID, + defaultTTL: defaultCacheTTL, + } +} + +// mapKey combines the plugin name and a provided key to create a unique cache key. +func (s *cacheServiceImpl) mapKey(key string) string { + return s.pluginID + ":" + key +} + +// getTTL converts seconds to a duration, using default if 0 +func (s *cacheServiceImpl) getTTL(seconds int64) time.Duration { + if seconds <= 0 { + return s.defaultTTL + } + return time.Duration(seconds) * time.Second +} + +// setCacheValue is a generic function to set a value in the cache +func setCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, value T, ttlSeconds int64) (*cacheproto.SetResponse, error) { + ttl := cs.getTTL(ttlSeconds) + key = cs.mapKey(key) + _cache.Set(key, value, ttl) + return &cacheproto.SetResponse{Success: true}, nil +} + +// getCacheValue is a generic function to get a value from the cache +func getCacheValue[T any](ctx context.Context, cs *cacheServiceImpl, key string, typeName string) (T, bool, error) { + key = cs.mapKey(key) + var zero T + item := _cache.Get(key) + if item == nil { + return zero, false, nil + } + + value, ok := item.Value().(T) + if !ok { + log.Debug(ctx, "Type mismatch in cache", "plugin", cs.pluginID, "key", key, "expected", typeName) + return zero, false, nil + } + return value, true, nil +} + +// SetString sets a string value in the cache +func (s *cacheServiceImpl) SetString(ctx context.Context, req *cacheproto.SetStringRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetString gets a string value from the cache +func (s *cacheServiceImpl) GetString(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetStringResponse, error) { + value, exists, err := getCacheValue[string](ctx, s, req.Key, "string") + if err != nil { + return nil, err + } + return &cacheproto.GetStringResponse{Exists: exists, Value: value}, nil +} + +// SetInt sets an integer value in the cache +func (s *cacheServiceImpl) SetInt(ctx context.Context, req *cacheproto.SetIntRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetInt gets an integer value from the cache +func (s *cacheServiceImpl) GetInt(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetIntResponse, error) { + value, exists, err := getCacheValue[int64](ctx, s, req.Key, "int64") + if err != nil { + return nil, err + } + return &cacheproto.GetIntResponse{Exists: exists, Value: value}, nil +} + +// SetFloat sets a float value in the cache +func (s *cacheServiceImpl) SetFloat(ctx context.Context, req *cacheproto.SetFloatRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetFloat gets a float value from the cache +func (s *cacheServiceImpl) GetFloat(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetFloatResponse, error) { + value, exists, err := getCacheValue[float64](ctx, s, req.Key, "float64") + if err != nil { + return nil, err + } + return &cacheproto.GetFloatResponse{Exists: exists, Value: value}, nil +} + +// SetBytes sets a byte slice value in the cache +func (s *cacheServiceImpl) SetBytes(ctx context.Context, req *cacheproto.SetBytesRequest) (*cacheproto.SetResponse, error) { + return setCacheValue(ctx, s, req.Key, req.Value, req.TtlSeconds) +} + +// GetBytes gets a byte slice value from the cache +func (s *cacheServiceImpl) GetBytes(ctx context.Context, req *cacheproto.GetRequest) (*cacheproto.GetBytesResponse, error) { + value, exists, err := getCacheValue[[]byte](ctx, s, req.Key, "[]byte") + if err != nil { + return nil, err + } + return &cacheproto.GetBytesResponse{Exists: exists, Value: value}, nil +} + +// Remove removes a value from the cache +func (s *cacheServiceImpl) Remove(ctx context.Context, req *cacheproto.RemoveRequest) (*cacheproto.RemoveResponse, error) { + key := s.mapKey(req.Key) + _cache.Delete(key) + return &cacheproto.RemoveResponse{Success: true}, nil +} + +// Has checks if a key exists in the cache +func (s *cacheServiceImpl) Has(ctx context.Context, req *cacheproto.HasRequest) (*cacheproto.HasResponse, error) { + key := s.mapKey(req.Key) + item := _cache.Get(key) + return &cacheproto.HasResponse{Exists: item != nil}, nil +} diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go new file mode 100644 index 0000000..efb03e2 --- /dev/null +++ b/plugins/host_cache_test.go @@ -0,0 +1,171 @@ +package plugins + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/plugins/host/cache" + . "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") + }) + + 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("String Operations", func() { + It("sets and gets a string value", func() { + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "string_key", + Value: "test_value", + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetString(ctx, &cache.GetRequest{Key: "string_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal("test_value")) + }) + + It("returns not exists for missing key", func() { + res, err := service.GetString(ctx, &cache.GetRequest{Key: "missing_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) + + Describe("Integer Operations", func() { + It("sets and gets an integer value", func() { + _, err := service.SetInt(ctx, &cache.SetIntRequest{ + Key: "int_key", + Value: 42, + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetInt(ctx, &cache.GetRequest{Key: "int_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal(int64(42))) + }) + }) + + Describe("Float Operations", func() { + It("sets and gets a float value", func() { + _, err := service.SetFloat(ctx, &cache.SetFloatRequest{ + Key: "float_key", + Value: 3.14, + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetFloat(ctx, &cache.GetRequest{Key: "float_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal(3.14)) + }) + }) + + Describe("Bytes Operations", func() { + It("sets and gets a bytes value", func() { + byteData := []byte("hello world") + _, err := service.SetBytes(ctx, &cache.SetBytesRequest{ + Key: "bytes_key", + Value: byteData, + TtlSeconds: 300, + }) + Expect(err).NotTo(HaveOccurred()) + + res, err := service.GetBytes(ctx, &cache.GetRequest{Key: "bytes_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + Expect(res.Value).To(Equal(byteData)) + }) + }) + + Describe("Type mismatch handling", func() { + It("returns not exists when type doesn't match the getter", func() { + // Set string + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "mixed_key", + Value: "string value", + }) + Expect(err).NotTo(HaveOccurred()) + + // Try to get as int + res, err := service.GetInt(ctx, &cache.GetRequest{Key: "mixed_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) + + Describe("Remove Operation", func() { + It("removes a value from the cache", func() { + // Set a value + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "remove_key", + Value: "to be removed", + }) + Expect(err).NotTo(HaveOccurred()) + + // Verify it exists + res, err := service.Has(ctx, &cache.HasRequest{Key: "remove_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + + // Remove it + _, err = service.Remove(ctx, &cache.RemoveRequest{Key: "remove_key"}) + Expect(err).NotTo(HaveOccurred()) + + // Verify it's gone + res, err = service.Has(ctx, &cache.HasRequest{Key: "remove_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) + + Describe("Has Operation", func() { + It("returns true for existing key", func() { + // Set a value + _, err := service.SetString(ctx, &cache.SetStringRequest{ + Key: "existing_key", + Value: "exists", + }) + Expect(err).NotTo(HaveOccurred()) + + // Check if it exists + res, err := service.Has(ctx, &cache.HasRequest{Key: "existing_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeTrue()) + }) + + It("returns false for non-existing key", func() { + res, err := service.Has(ctx, &cache.HasRequest{Key: "non_existing_key"}) + Expect(err).NotTo(HaveOccurred()) + Expect(res.Exists).To(BeFalse()) + }) + }) +}) diff --git a/plugins/host_config.go b/plugins/host_config.go new file mode 100644 index 0000000..baee6a0 --- /dev/null +++ b/plugins/host_config.go @@ -0,0 +1,22 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/plugins/host/config" +) + +type configServiceImpl struct { + pluginID string +} + +func (c *configServiceImpl) GetPluginConfig(ctx context.Context, req *config.GetPluginConfigRequest) (*config.GetPluginConfigResponse, error) { + cfg, ok := conf.Server.PluginConfig[c.pluginID] + if !ok { + cfg = map[string]string{} + } + return &config.GetPluginConfigResponse{ + Config: cfg, + }, nil +} diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go new file mode 100644 index 0000000..bae7043 --- /dev/null +++ b/plugins/host_config_test.go @@ -0,0 +1,46 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/conf" + hostconfig "github.com/navidrome/navidrome/plugins/host/config" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("configServiceImpl", func() { + var ( + svc *configServiceImpl + pluginName string + ) + + BeforeEach(func() { + pluginName = "testplugin" + svc = &configServiceImpl{pluginID: pluginName} + conf.Server.PluginConfig = map[string]map[string]string{ + pluginName: {"foo": "bar", "baz": "qux"}, + } + }) + + It("returns config for known plugin", func() { + resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{}) + Expect(err).To(BeNil()) + Expect(resp.Config).To(HaveKeyWithValue("foo", "bar")) + Expect(resp.Config).To(HaveKeyWithValue("baz", "qux")) + }) + + It("returns error for unknown plugin", func() { + svc.pluginID = "unknown" + resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{}) + Expect(err).To(BeNil()) + Expect(resp.Config).To(BeEmpty()) + }) + + It("returns empty config if plugin config is empty", func() { + conf.Server.PluginConfig[pluginName] = map[string]string{} + resp, err := svc.GetPluginConfig(context.Background(), &hostconfig.GetPluginConfigRequest{}) + Expect(err).To(BeNil()) + Expect(resp.Config).To(BeEmpty()) + }) +}) diff --git a/plugins/host_http.go b/plugins/host_http.go new file mode 100644 index 0000000..24fc77b --- /dev/null +++ b/plugins/host_http.go @@ -0,0 +1,114 @@ +package plugins + +import ( + "bytes" + "cmp" + "context" + "io" + "net/http" + "time" + + "github.com/navidrome/navidrome/log" + hosthttp "github.com/navidrome/navidrome/plugins/host/http" +) + +type httpServiceImpl struct { + pluginID string + permissions *httpPermissions +} + +const defaultTimeout = 10 * time.Second + +func (s *httpServiceImpl) Get(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodGet, req) +} + +func (s *httpServiceImpl) Post(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodPost, req) +} + +func (s *httpServiceImpl) Put(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodPut, req) +} + +func (s *httpServiceImpl) Delete(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodDelete, req) +} + +func (s *httpServiceImpl) Patch(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodPatch, req) +} + +func (s *httpServiceImpl) Head(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodHead, req) +} + +func (s *httpServiceImpl) Options(ctx context.Context, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + return s.doHttp(ctx, http.MethodOptions, req) +} + +func (s *httpServiceImpl) doHttp(ctx context.Context, method string, req *hosthttp.HttpRequest) (*hosthttp.HttpResponse, error) { + // Check permissions if they exist + if s.permissions != nil { + if err := s.permissions.IsRequestAllowed(req.Url, method); err != nil { + log.Warn(ctx, "HTTP request blocked by permissions", "plugin", s.pluginID, "url", req.Url, "method", method, err) + return &hosthttp.HttpResponse{Error: "Request blocked by plugin permissions: " + err.Error()}, nil + } + } + client := &http.Client{ + Timeout: cmp.Or(time.Duration(req.TimeoutMs)*time.Millisecond, defaultTimeout), + } + + // Configure redirect policy based on permissions + if s.permissions != nil { + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + // Enforce maximum redirect limit + if len(via) >= httpMaxRedirects { + log.Warn(ctx, "HTTP redirect limit exceeded", "plugin", s.pluginID, "url", req.URL.String(), "redirectCount", len(via)) + return http.ErrUseLastResponse + } + + // Check if redirect destination is allowed + if err := s.permissions.IsRequestAllowed(req.URL.String(), req.Method); err != nil { + log.Warn(ctx, "HTTP redirect blocked by permissions", "plugin", s.pluginID, "url", req.URL.String(), "method", req.Method, err) + return http.ErrUseLastResponse + } + + return nil // Allow redirect + } + } + var body io.Reader + if method == http.MethodPost || method == http.MethodPut || method == http.MethodPatch { + body = bytes.NewReader(req.Body) + } + httpReq, err := http.NewRequestWithContext(ctx, method, req.Url, body) + if err != nil { + return nil, err + } + for k, v := range req.Headers { + httpReq.Header.Set(k, v) + } + resp, err := client.Do(httpReq) + if err != nil { + log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, err) + return &hosthttp.HttpResponse{Error: err.Error()}, nil + } + log.Trace(ctx, "HttpService request", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode) + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Trace(ctx, "HttpService request error", "method", method, "url", req.Url, "headers", req.Headers, "resp.status", resp.StatusCode, err) + return &hosthttp.HttpResponse{Error: err.Error()}, nil + } + headers := map[string]string{} + for k, v := range resp.Header { + if len(v) > 0 { + headers[k] = v[0] + } + } + return &hosthttp.HttpResponse{ + Status: int32(resp.StatusCode), + Body: respBody, + Headers: headers, + }, nil +} diff --git a/plugins/host_http_permissions.go b/plugins/host_http_permissions.go new file mode 100644 index 0000000..158bdb1 --- /dev/null +++ b/plugins/host_http_permissions.go @@ -0,0 +1,90 @@ +package plugins + +import ( + "fmt" + "strings" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// Maximum number of HTTP redirects allowed for plugin requests +const httpMaxRedirects = 5 + +// HTTPPermissions represents granular HTTP access permissions for plugins +type httpPermissions struct { + *networkPermissionsBase + AllowedUrls map[string][]string `json:"allowedUrls"` + matcher *urlMatcher +} + +// parseHTTPPermissions extracts HTTP permissions from the schema +func parseHTTPPermissions(permData *schema.PluginManifestPermissionsHttp) (*httpPermissions, error) { + base := &networkPermissionsBase{ + AllowLocalNetwork: permData.AllowLocalNetwork, + } + + if len(permData.AllowedUrls) == 0 { + return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern") + } + + allowedUrls := make(map[string][]string) + for urlPattern, methodEnums := range permData.AllowedUrls { + methods := make([]string, len(methodEnums)) + for i, methodEnum := range methodEnums { + methods[i] = string(methodEnum) + } + allowedUrls[urlPattern] = methods + } + + return &httpPermissions{ + networkPermissionsBase: base, + AllowedUrls: allowedUrls, + matcher: newURLMatcher(), + }, nil +} + +// IsRequestAllowed checks if a specific network request is allowed by the permissions +func (p *httpPermissions) IsRequestAllowed(requestURL, operation string) error { + if _, err := checkURLPolicy(requestURL, p.AllowLocalNetwork); err != nil { + return err + } + + // allowedUrls is now required - no fallback to allow all URLs + if p.AllowedUrls == nil || len(p.AllowedUrls) == 0 { + return fmt.Errorf("no allowed URLs configured for plugin") + } + + matcher := newURLMatcher() + + // Check URL patterns and operations + // First try exact matches, then wildcard matches + operation = strings.ToUpper(operation) + + // Phase 1: Check for exact matches first + for urlPattern, allowedOperations := range p.AllowedUrls { + if !strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) { + // Check if operation is allowed + for _, allowedOperation := range allowedOperations { + if allowedOperation == "*" || allowedOperation == operation { + return nil + } + } + return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern) + } + } + + // Phase 2: Check wildcard patterns + for urlPattern, allowedOperations := range p.AllowedUrls { + if strings.Contains(urlPattern, "*") && matcher.MatchesURLPattern(requestURL, urlPattern) { + // Check if operation is allowed + for _, allowedOperation := range allowedOperations { + if allowedOperation == "*" || allowedOperation == operation { + return nil + } + } + return fmt.Errorf("operation %s not allowed for URL pattern %s", operation, urlPattern) + } + } + + return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL) +} diff --git a/plugins/host_http_permissions_test.go b/plugins/host_http_permissions_test.go new file mode 100644 index 0000000..3385ffc --- /dev/null +++ b/plugins/host_http_permissions_test.go @@ -0,0 +1,187 @@ +package plugins + +import ( + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("HTTP Permissions", func() { + Describe("parseHTTPPermissions", func() { + It("should parse valid HTTP permissions", func() { + permData := &schema.PluginManifestPermissionsHttp{ + Reason: "Need to fetch album artwork", + AllowLocalNetwork: false, + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "https://api.example.com/*": { + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET, + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemPOST, + }, + "https://cdn.example.com/*": { + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemGET, + }, + }, + } + + perms, err := parseHTTPPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms).ToNot(BeNil()) + Expect(perms.AllowLocalNetwork).To(BeFalse()) + Expect(perms.AllowedUrls).To(HaveLen(2)) + Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"GET", "POST"})) + Expect(perms.AllowedUrls["https://cdn.example.com/*"]).To(Equal([]string{"GET"})) + }) + + It("should fail if allowedUrls is empty", func() { + permData := &schema.PluginManifestPermissionsHttp{ + Reason: "Need to fetch album artwork", + AllowLocalNetwork: false, + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{}, + } + + _, err := parseHTTPPermissions(permData) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern")) + }) + + It("should handle method enum types correctly", func() { + permData := &schema.PluginManifestPermissionsHttp{ + Reason: "Need to fetch album artwork", + AllowLocalNetwork: false, + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "https://api.example.com/*": { + schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard, // "*" + }, + }, + } + + perms, err := parseHTTPPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms.AllowedUrls["https://api.example.com/*"]).To(Equal([]string{"*"})) + }) + }) + + Describe("IsRequestAllowed", func() { + var perms *httpPermissions + + Context("HTTP method-specific validation", func() { + BeforeEach(func() { + perms = &httpPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + Reason: "Test permissions", + AllowLocalNetwork: false, + }, + AllowedUrls: map[string][]string{ + "https://api.example.com": {"GET", "POST"}, + "https://upload.example.com": {"PUT", "PATCH"}, + "https://admin.example.com": {"DELETE"}, + "https://webhook.example.com": {"*"}, + }, + matcher: newURLMatcher(), + } + }) + + DescribeTable("method-specific access control", + func(url, method string, shouldSucceed bool) { + err := perms.IsRequestAllowed(url, method) + if shouldSucceed { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + // Allowed methods + Entry("GET to api", "https://api.example.com", "GET", true), + Entry("POST to api", "https://api.example.com", "POST", true), + Entry("PUT to upload", "https://upload.example.com", "PUT", true), + Entry("PATCH to upload", "https://upload.example.com", "PATCH", true), + Entry("DELETE to admin", "https://admin.example.com", "DELETE", true), + Entry("any method to webhook", "https://webhook.example.com", "OPTIONS", true), + Entry("any method to webhook", "https://webhook.example.com", "HEAD", true), + + // Disallowed methods + Entry("DELETE to api", "https://api.example.com", "DELETE", false), + Entry("GET to upload", "https://upload.example.com", "GET", false), + Entry("POST to admin", "https://admin.example.com", "POST", false), + ) + }) + + Context("case insensitive method handling", func() { + BeforeEach(func() { + perms = &httpPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + Reason: "Test permissions", + AllowLocalNetwork: false, + }, + AllowedUrls: map[string][]string{ + "https://api.example.com": {"GET", "POST"}, // Both uppercase for consistency + }, + matcher: newURLMatcher(), + } + }) + + DescribeTable("case insensitive method matching", + func(method string, shouldSucceed bool) { + err := perms.IsRequestAllowed("https://api.example.com", method) + if shouldSucceed { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + Entry("uppercase GET", "GET", true), + Entry("lowercase get", "get", true), + Entry("mixed case Get", "Get", true), + Entry("uppercase POST", "POST", true), + Entry("lowercase post", "post", true), + Entry("mixed case Post", "Post", true), + Entry("disallowed method", "DELETE", false), + ) + }) + + Context("with complex URL patterns and HTTP methods", func() { + BeforeEach(func() { + perms = &httpPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + Reason: "Test permissions", + AllowLocalNetwork: false, + }, + AllowedUrls: map[string][]string{ + "https://api.example.com/v1/*": {"GET"}, + "https://api.example.com/v1/users": {"POST", "PUT"}, + "https://*.example.com/public/*": {"GET", "HEAD"}, + "https://admin.*.example.com": {"*"}, + }, + matcher: newURLMatcher(), + } + }) + + DescribeTable("complex pattern and method combinations", + func(url, method string, shouldSucceed bool) { + err := perms.IsRequestAllowed(url, method) + if shouldSucceed { + Expect(err).ToNot(HaveOccurred()) + } else { + Expect(err).To(HaveOccurred()) + } + }, + // Path wildcards with specific methods + Entry("GET to v1 path", "https://api.example.com/v1/posts", "GET", true), + Entry("POST to v1 path", "https://api.example.com/v1/posts", "POST", false), + Entry("POST to specific users endpoint", "https://api.example.com/v1/users", "POST", true), + Entry("PUT to specific users endpoint", "https://api.example.com/v1/users", "PUT", true), + Entry("DELETE to specific users endpoint", "https://api.example.com/v1/users", "DELETE", false), + + // Subdomain wildcards with specific methods + Entry("GET to public path on subdomain", "https://cdn.example.com/public/assets", "GET", true), + Entry("HEAD to public path on subdomain", "https://static.example.com/public/files", "HEAD", true), + Entry("POST to public path on subdomain", "https://api.example.com/public/upload", "POST", false), + + // Admin subdomain with all methods + Entry("GET to admin subdomain", "https://admin.prod.example.com", "GET", true), + Entry("POST to admin subdomain", "https://admin.staging.example.com", "POST", true), + Entry("DELETE to admin subdomain", "https://admin.dev.example.com", "DELETE", true), + ) + }) + }) +}) diff --git a/plugins/host_http_test.go b/plugins/host_http_test.go new file mode 100644 index 0000000..b6f823a --- /dev/null +++ b/plugins/host_http_test.go @@ -0,0 +1,190 @@ +package plugins + +import ( + "context" + "net/http" + "net/http/httptest" + "time" + + hosthttp "github.com/navidrome/navidrome/plugins/host/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("httpServiceImpl", func() { + var ( + svc *httpServiceImpl + ts *httptest.Server + ) + + BeforeEach(func() { + svc = &httpServiceImpl{} + }) + + AfterEach(func() { + if ts != nil { + ts.Close() + } + }) + + It("should handle GET requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test", "ok") + w.WriteHeader(201) + _, _ = w.Write([]byte("hello")) + })) + resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Headers: map[string]string{"A": "B"}, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).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) { + b := make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + _, _ = w.Write([]byte("got:" + string(b))) + })) + resp, err := svc.Post(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Body: []byte("abc"), + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + 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) { + b := make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + _, _ = w.Write([]byte("put:" + string(b))) + })) + resp, err := svc.Put(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Body: []byte("xyz"), + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + 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) { + w.WriteHeader(204) + })) + resp, err := svc.Delete(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).To(Equal(int32(204))) + }) + + It("should handle PATCH requests with body", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, r.ContentLength) + _, _ = r.Body.Read(b) + _, _ = w.Write([]byte("patch:" + string(b))) + })) + resp, err := svc.Patch(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + Body: []byte("test-patch"), + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(string(resp.Body)).To(Equal("patch:test-patch")) + }) + + It("should handle HEAD requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", "42") + w.WriteHeader(200) + // HEAD responses shouldn't have a body, but the headers should be present + })) + resp, err := svc.Head(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).To(Equal(int32(200))) + Expect(resp.Headers["Content-Type"]).To(Equal("application/json")) + Expect(resp.Headers["Content-Length"]).To(Equal("42")) + Expect(resp.Body).To(BeEmpty()) // HEAD responses have no body + }) + + It("should handle OPTIONS requests", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Allow", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS") + w.WriteHeader(200) + })) + resp, err := svc.Options(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Status).To(Equal(int32(200))) + Expect(resp.Headers["Allow"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")) + Expect(resp.Headers["Access-Control-Allow-Methods"]).To(Equal("GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS")) + }) + + It("should handle timeouts and errors", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + resp, err := svc.Get(context.Background(), &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1, + }) + Expect(err).To(BeNil()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Error).To(ContainSubstring("deadline exceeded")) + }) + + It("should return error on context timeout", func() { + ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(50 * time.Millisecond) + })) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond) + defer cancel() + resp, err := svc.Get(ctx, &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Error).To(ContainSubstring("context 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() + }() + resp, err := svc.Get(ctx, &hosthttp.HttpRequest{ + Url: ts.URL, + TimeoutMs: 1000, + }) + Expect(err).To(BeNil()) + Expect(resp).NotTo(BeNil()) + Expect(resp.Error).To(ContainSubstring("context canceled")) + }) +}) diff --git a/plugins/host_network_permissions_base.go b/plugins/host_network_permissions_base.go new file mode 100644 index 0000000..c3224fe --- /dev/null +++ b/plugins/host_network_permissions_base.go @@ -0,0 +1,192 @@ +package plugins + +import ( + "fmt" + "net" + "net/url" + "regexp" + "strings" +) + +// NetworkPermissionsBase contains common functionality for network-based permissions +type networkPermissionsBase struct { + Reason string `json:"reason"` + AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty"` +} + +// URLMatcher provides URL pattern matching functionality +type urlMatcher struct{} + +// newURLMatcher creates a new URL matcher instance +func newURLMatcher() *urlMatcher { + return &urlMatcher{} +} + +// checkURLPolicy performs common checks for a URL against network policies. +func checkURLPolicy(requestURL string, allowLocalNetwork bool) (*url.URL, error) { + parsedURL, err := url.Parse(requestURL) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + // Check local network restrictions + if !allowLocalNetwork { + if err := checkLocalNetwork(parsedURL); err != nil { + return nil, err + } + } + return parsedURL, nil +} + +// MatchesURLPattern checks if a URL matches a given pattern +func (m *urlMatcher) MatchesURLPattern(requestURL, pattern string) bool { + // Handle wildcard pattern + if pattern == "*" { + return true + } + + // Parse both URLs to handle path matching correctly + reqURL, err := url.Parse(requestURL) + if err != nil { + return false + } + + patternURL, err := url.Parse(pattern) + if err != nil { + // If pattern is not a valid URL, treat it as a simple string pattern + regexPattern := m.urlPatternToRegex(pattern) + matched, err := regexp.MatchString(regexPattern, requestURL) + if err != nil { + return false + } + return matched + } + + // Match scheme + if patternURL.Scheme != "" && patternURL.Scheme != reqURL.Scheme { + return false + } + + // Match host with wildcard support + if !m.matchesHost(reqURL.Host, patternURL.Host) { + return false + } + + // Match path with wildcard support + // Special case: if pattern URL has empty path and contains wildcards, allow any path (domain-only wildcard matching) + if (patternURL.Path == "" || patternURL.Path == "/") && strings.Contains(pattern, "*") { + // This is a domain-only wildcard pattern, allow any path + return true + } + if !m.matchesPath(reqURL.Path, patternURL.Path) { + return false + } + + return true +} + +// urlPatternToRegex converts a URL pattern with wildcards to a regex pattern +func (m *urlMatcher) urlPatternToRegex(pattern string) string { + // Escape special regex characters except * + escaped := regexp.QuoteMeta(pattern) + + // Replace escaped \* with regex pattern for wildcard matching + // For subdomain: *.example.com -> [^.]*\.example\.com + // For path: /api/* -> /api/.* + escaped = strings.ReplaceAll(escaped, "\\*", ".*") + + // Anchor the pattern to match the full URL + return "^" + escaped + "$" +} + +// matchesHost checks if a host matches a pattern with wildcard support +func (m *urlMatcher) matchesHost(host, pattern string) bool { + if pattern == "" { + return true + } + + if pattern == "*" { + return true + } + + // Handle wildcard patterns anywhere in the host + if strings.Contains(pattern, "*") { + patterns := []string{ + strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[0-9.]+"), // IP pattern + strings.ReplaceAll(regexp.QuoteMeta(pattern), "\\*", "[^.]*"), // Domain pattern + } + + for _, regexPattern := range patterns { + fullPattern := "^" + regexPattern + "$" + if matched, err := regexp.MatchString(fullPattern, host); err == nil && matched { + return true + } + } + return false + } + + return host == pattern +} + +// matchesPath checks if a path matches a pattern with wildcard support +func (m *urlMatcher) matchesPath(path, pattern string) bool { + // Normalize empty paths to "/" + if path == "" { + path = "/" + } + if pattern == "" { + pattern = "/" + } + + if pattern == "*" { + return true + } + + // Handle wildcard paths + if strings.HasSuffix(pattern, "/*") { + prefix := pattern[:len(pattern)-2] // Remove "/*" + if prefix == "" { + prefix = "/" + } + return strings.HasPrefix(path, prefix) + } + + return path == pattern +} + +// CheckLocalNetwork checks if the URL is accessing local network resources +func checkLocalNetwork(parsedURL *url.URL) error { + host := parsedURL.Hostname() + + // Check for localhost variants + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return fmt.Errorf("requests to localhost are not allowed") + } + + // Try to parse as IP address + ip := net.ParseIP(host) + if ip != nil && isPrivateIP(ip) { + return fmt.Errorf("requests to private IP addresses are not allowed") + } + + return nil +} + +// IsPrivateIP checks if an IP is loopback, private, or link-local (IPv4/IPv6). +func isPrivateIP(ip net.IP) bool { + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() { + return true + } + // IPv4 link-local: 169.254.0.0/16 + if ip4 := ip.To4(); ip4 != nil { + return ip4[0] == 169 && ip4[1] == 254 + } + // IPv6 link-local: fe80::/10 + if ip16 := ip.To16(); ip16 != nil && ip.To4() == nil { + return ip16[0] == 0xfe && (ip16[1]&0xc0) == 0x80 + } + return false +} diff --git a/plugins/host_network_permissions_base_test.go b/plugins/host_network_permissions_base_test.go new file mode 100644 index 0000000..9147e99 --- /dev/null +++ b/plugins/host_network_permissions_base_test.go @@ -0,0 +1,119 @@ +package plugins + +import ( + "net" + "net/url" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("networkPermissionsBase", func() { + Describe("urlMatcher", func() { + var matcher *urlMatcher + + BeforeEach(func() { + matcher = newURLMatcher() + }) + + Describe("MatchesURLPattern", func() { + DescribeTable("exact URL matching", + func(requestURL, pattern string, expected bool) { + result := matcher.MatchesURLPattern(requestURL, pattern) + Expect(result).To(Equal(expected)) + }, + Entry("exact match", "https://api.example.com", "https://api.example.com", true), + Entry("different domain", "https://api.example.com", "https://api.other.com", false), + Entry("different scheme", "http://api.example.com", "https://api.example.com", false), + Entry("different path", "https://api.example.com/v1", "https://api.example.com/v2", false), + ) + + DescribeTable("wildcard pattern matching", + func(requestURL, pattern string, expected bool) { + result := matcher.MatchesURLPattern(requestURL, pattern) + Expect(result).To(Equal(expected)) + }, + Entry("universal wildcard", "https://api.example.com", "*", true), + Entry("subdomain wildcard match", "https://api.example.com", "https://*.example.com", true), + Entry("subdomain wildcard non-match", "https://api.other.com", "https://*.example.com", false), + Entry("path wildcard match", "https://api.example.com/v1/users", "https://api.example.com/*", true), + Entry("path wildcard non-match", "https://other.example.com/v1", "https://api.example.com/*", false), + Entry("port wildcard match", "https://api.example.com:8080", "https://api.example.com:*", true), + ) + }) + }) + + Describe("isPrivateIP", func() { + DescribeTable("IPv4 private IP detection", + func(ip string, expected bool) { + parsedIP := net.ParseIP(ip) + Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip) + result := isPrivateIP(parsedIP) + Expect(result).To(Equal(expected)) + }, + // Private IPv4 ranges + Entry("10.0.0.1 (10.0.0.0/8)", "10.0.0.1", true), + Entry("10.255.255.255 (10.0.0.0/8)", "10.255.255.255", true), + Entry("172.16.0.1 (172.16.0.0/12)", "172.16.0.1", true), + Entry("172.31.255.255 (172.16.0.0/12)", "172.31.255.255", true), + Entry("192.168.1.1 (192.168.0.0/16)", "192.168.1.1", true), + Entry("192.168.255.255 (192.168.0.0/16)", "192.168.255.255", true), + Entry("127.0.0.1 (localhost)", "127.0.0.1", true), + Entry("127.255.255.255 (localhost)", "127.255.255.255", true), + Entry("169.254.1.1 (link-local)", "169.254.1.1", true), + Entry("169.254.255.255 (link-local)", "169.254.255.255", true), + + // Public IPv4 addresses + Entry("8.8.8.8 (Google DNS)", "8.8.8.8", false), + Entry("1.1.1.1 (Cloudflare DNS)", "1.1.1.1", false), + Entry("208.67.222.222 (OpenDNS)", "208.67.222.222", false), + Entry("172.15.255.255 (just outside 172.16.0.0/12)", "172.15.255.255", false), + Entry("172.32.0.1 (just outside 172.16.0.0/12)", "172.32.0.1", false), + ) + + DescribeTable("IPv6 private IP detection", + func(ip string, expected bool) { + parsedIP := net.ParseIP(ip) + Expect(parsedIP).ToNot(BeNil(), "Failed to parse IP: %s", ip) + result := isPrivateIP(parsedIP) + Expect(result).To(Equal(expected)) + }, + // Private IPv6 ranges + Entry("::1 (IPv6 localhost)", "::1", true), + Entry("fe80::1 (link-local)", "fe80::1", true), + Entry("fc00::1 (unique local)", "fc00::1", true), + Entry("fd00::1 (unique local)", "fd00::1", true), + + // Public IPv6 addresses + Entry("2001:4860:4860::8888 (Google DNS)", "2001:4860:4860::8888", false), + Entry("2606:4700:4700::1111 (Cloudflare DNS)", "2606:4700:4700::1111", false), + ) + }) + + Describe("checkLocalNetwork", func() { + DescribeTable("local network detection", + func(urlStr string, shouldError bool, expectedErrorSubstring string) { + parsedURL, err := url.Parse(urlStr) + Expect(err).ToNot(HaveOccurred()) + + err = checkLocalNetwork(parsedURL) + if shouldError { + Expect(err).To(HaveOccurred()) + if expectedErrorSubstring != "" { + Expect(err.Error()).To(ContainSubstring(expectedErrorSubstring)) + } + } else { + Expect(err).ToNot(HaveOccurred()) + } + }, + Entry("localhost", "http://localhost:8080", true, "localhost"), + Entry("127.0.0.1", "http://127.0.0.1:3000", true, "localhost"), + Entry("::1", "http://[::1]:8080", true, "localhost"), + Entry("private IP 192.168.1.100", "http://192.168.1.100", true, "private IP"), + Entry("private IP 10.0.0.1", "http://10.0.0.1", true, "private IP"), + Entry("private IP 172.16.0.1", "http://172.16.0.1", true, "private IP"), + Entry("public IP 8.8.8.8", "http://8.8.8.8", false, ""), + Entry("public domain", "https://api.example.com", false, ""), + ) + }) +}) diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go new file mode 100644 index 0000000..26c5e92 --- /dev/null +++ b/plugins/host_scheduler.go @@ -0,0 +1,338 @@ +package plugins + +import ( + "context" + "fmt" + "sync" + "time" + + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/host/scheduler" + navidsched "github.com/navidrome/navidrome/scheduler" +) + +const ( + ScheduleTypeOneTime = "one-time" + ScheduleTypeRecurring = "recurring" +) + +// ScheduledCallback represents a registered schedule callback +type ScheduledCallback struct { + ID string + PluginID string + Type string // "one-time" or "recurring" + Payload []byte + EntryID int // Used for recurring schedules via the scheduler + Cancel context.CancelFunc // Used for one-time schedules +} + +// SchedulerHostFunctions implements the scheduler.SchedulerService interface +type SchedulerHostFunctions struct { + ss *schedulerService + pluginID string +} + +func (s SchedulerHostFunctions) ScheduleOneTime(ctx context.Context, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) { + return s.ss.scheduleOneTime(ctx, s.pluginID, req) +} + +func (s SchedulerHostFunctions) ScheduleRecurring(ctx context.Context, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) { + return s.ss.scheduleRecurring(ctx, s.pluginID, req) +} + +func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) { + return s.ss.cancelSchedule(ctx, s.pluginID, req) +} + +func (s SchedulerHostFunctions) TimeNow(ctx context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + return s.ss.timeNow(ctx, req) +} + +type schedulerService struct { + // Map of schedule IDs to their callback info + schedules map[string]*ScheduledCallback + manager *managerImpl + navidSched navidsched.Scheduler // Navidrome scheduler for recurring jobs + mu sync.Mutex +} + +// newSchedulerService creates a new schedulerService instance +func newSchedulerService(manager *managerImpl) *schedulerService { + return &schedulerService{ + schedules: make(map[string]*ScheduledCallback), + manager: manager, + navidSched: navidsched.GetInstance(), + } +} + +func (s *schedulerService) HostFunctions(pluginID string) SchedulerHostFunctions { + return SchedulerHostFunctions{ + ss: s, + pluginID: pluginID, + } +} + +// Safe accessor methods for tests + +// hasSchedule safely checks if a schedule exists +func (s *schedulerService) hasSchedule(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, exists := s.schedules[id] + return exists +} + +// scheduleCount safely returns the number of schedules +func (s *schedulerService) scheduleCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.schedules) +} + +// getScheduleType safely returns the type of a schedule +func (s *schedulerService) getScheduleType(id string) string { + s.mu.Lock() + defer s.mu.Unlock() + if cb, exists := s.schedules[id]; exists { + return cb.Type + } + return "" +} + +// scheduleJob is a helper function that handles the common logic for scheduling jobs +func (s *schedulerService) scheduleJob(pluginID string, scheduleId string, jobType string, payload []byte) (string, *ScheduledCallback, context.CancelFunc, error) { + if s.manager == nil { + return "", nil, nil, fmt.Errorf("scheduler service not properly initialized") + } + + // Original scheduleId (what the plugin will see) + originalScheduleId := scheduleId + if originalScheduleId == "" { + // Generate a random ID if one wasn't provided + originalScheduleId, _ = gonanoid.New(10) + } + + // Internal scheduleId (prefixed with plugin name to avoid conflicts) + internalScheduleId := pluginID + ":" + originalScheduleId + + // Store any existing cancellation function to call after we've updated the map + var cancelExisting context.CancelFunc + + // Check if there's an existing schedule with the same ID, we'll cancel it after updating the map + if existingSchedule, ok := s.schedules[internalScheduleId]; ok { + log.Debug("Replacing existing schedule with same ID", "plugin", pluginID, "scheduleID", originalScheduleId) + + // Store cancel information but don't call it yet + if existingSchedule.Type == ScheduleTypeOneTime && existingSchedule.Cancel != nil { + // We'll set the Cancel to nil to prevent the old job from removing the new one + cancelExisting = existingSchedule.Cancel + existingSchedule.Cancel = nil + } else if existingSchedule.Type == ScheduleTypeRecurring { + existingRecurringEntryID := existingSchedule.EntryID + if existingRecurringEntryID != 0 { + s.navidSched.Remove(existingRecurringEntryID) + } + } + } + + // Create the callback object + callback := &ScheduledCallback{ + ID: originalScheduleId, + PluginID: pluginID, + Type: jobType, + Payload: payload, + } + + return internalScheduleId, callback, cancelExisting, nil +} + +// scheduleOneTime registers a new one-time scheduled job +func (s *schedulerService) scheduleOneTime(_ context.Context, pluginID string, req *scheduler.ScheduleOneTimeRequest) (*scheduler.ScheduleResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeOneTime, req.Payload) + if err != nil { + return nil, err + } + + // Create a context with cancel for this one-time schedule + scheduleCtx, cancel := context.WithCancel(context.Background()) + callback.Cancel = cancel + + // Store the callback info + s.schedules[internalScheduleId] = callback + + // Now that the new job is in the map, we can safely cancel the old one + if cancelExisting != nil { + // Cancel in a goroutine to avoid deadlock since we're already holding the lock + go cancelExisting() + } + + log.Debug("One-time schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId) + + // Start the timer goroutine with the internal ID + go s.runOneTimeSchedule(scheduleCtx, internalScheduleId, time.Duration(req.DelaySeconds)*time.Second) + + // Return the original ID to the plugin + return &scheduler.ScheduleResponse{ + ScheduleId: callback.ID, + }, nil +} + +// scheduleRecurring registers a new recurring scheduled job +func (s *schedulerService) scheduleRecurring(_ context.Context, pluginID string, req *scheduler.ScheduleRecurringRequest) (*scheduler.ScheduleResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + internalScheduleId, callback, cancelExisting, err := s.scheduleJob(pluginID, req.ScheduleId, ScheduleTypeRecurring, req.Payload) + if err != nil { + return nil, err + } + + // Schedule the job with the Navidrome scheduler + entryID, err := s.navidSched.Add(req.CronExpression, func() { + s.executeCallback(context.Background(), internalScheduleId, true) + }) + if err != nil { + return nil, fmt.Errorf("failed to schedule recurring job: %w", err) + } + + // Store the entry ID so we can cancel it later + callback.EntryID = entryID + + // Store the callback info + s.schedules[internalScheduleId] = callback + + // Now that the new job is in the map, we can safely cancel the old one + if cancelExisting != nil { + // Cancel in a goroutine to avoid deadlock since we're already holding the lock + go cancelExisting() + } + + log.Debug("Recurring schedule registered", "plugin", pluginID, "scheduleID", callback.ID, "internalID", internalScheduleId, "cron", req.CronExpression) + + // Return the original ID to the plugin + return &scheduler.ScheduleResponse{ + ScheduleId: callback.ID, + }, nil +} + +// cancelSchedule cancels a scheduled job (either one-time or recurring) +func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, req *scheduler.CancelRequest) (*scheduler.CancelResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + + internalScheduleId := pluginID + ":" + req.ScheduleId + callback, exists := s.schedules[internalScheduleId] + if !exists { + return &scheduler.CancelResponse{ + Success: false, + Error: "schedule not found", + }, nil + } + + // Store the cancel functions to call after we've updated the schedule map + var cancelFunc context.CancelFunc + var recurringEntryID int + + // Store cancel information but don't call it yet + if callback.Type == ScheduleTypeOneTime && callback.Cancel != nil { + cancelFunc = callback.Cancel + callback.Cancel = nil // Set to nil to prevent the cancel handler from removing the job + } else if callback.Type == ScheduleTypeRecurring { + recurringEntryID = callback.EntryID + } + + // First remove from the map + delete(s.schedules, internalScheduleId) + + // Now perform the cancellation safely + if cancelFunc != nil { + // Execute in a goroutine to avoid deadlock since we're already holding the lock + go cancelFunc() + } + if recurringEntryID != 0 { + s.navidSched.Remove(recurringEntryID) + } + + log.Debug("Schedule canceled", "plugin", pluginID, "scheduleID", req.ScheduleId, "internalID", internalScheduleId, "type", callback.Type) + + return &scheduler.CancelResponse{ + Success: true, + }, nil +} + +// timeNow returns the current time in multiple formats +func (s *schedulerService) timeNow(_ context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + now := time.Now() + + return &scheduler.TimeNowResponse{ + Rfc3339Nano: now.Format(time.RFC3339Nano), + UnixMilli: now.UnixMilli(), + LocalTimeZone: now.Location().String(), + }, nil +} + +// runOneTimeSchedule handles the one-time schedule execution and callback +func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) { + tmr := time.NewTimer(delay) + defer tmr.Stop() + + select { + case <-ctx.Done(): + // Schedule was cancelled via its context + // We're no longer removing the schedule here because that's handled by the code that + // cancelled the context + log.Debug("One-time schedule context canceled", "internalID", internalScheduleId) + return + + case <-tmr.C: + // Timer fired, execute the callback + s.executeCallback(ctx, internalScheduleId, false) + } +} + +// executeCallback calls the plugin's OnSchedulerCallback method +func (s *schedulerService) executeCallback(ctx context.Context, internalScheduleId string, isRecurring bool) { + s.mu.Lock() + callback := s.schedules[internalScheduleId] + // Only remove one-time schedules from the map after execution + if callback != nil && callback.Type == ScheduleTypeOneTime { + delete(s.schedules, internalScheduleId) + } + s.mu.Unlock() + + if callback == nil { + log.Error("Schedule not found for callback", "internalID", internalScheduleId) + return + } + + ctx = log.NewContext(ctx, "plugin", callback.PluginID, "scheduleID", callback.ID, "type", callback.Type) + log.Debug("Executing schedule callback") + start := time.Now() + + // Get the plugin + p := s.manager.LoadPlugin(callback.PluginID, CapabilitySchedulerCallback) + if p == nil { + log.Error("Plugin not found for callback", "plugin", callback.PluginID) + return + } + + // Type-check the plugin + plugin, ok := p.(*wasmSchedulerCallback) + if !ok { + log.Error("Plugin does not implement SchedulerCallback", "plugin", callback.PluginID) + return + } + + // Call the plugin's OnSchedulerCallback method + log.Trace(ctx, "Executing schedule callback") + err := plugin.OnSchedulerCallback(ctx, callback.ID, callback.Payload, isRecurring) + if err != nil { + log.Error("Error executing schedule callback", "elapsed", time.Since(start), err) + return + } + log.Debug("Schedule callback executed", "elapsed", time.Since(start)) +} diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go new file mode 100644 index 0000000..1a3efaa --- /dev/null +++ b/plugins/host_scheduler_test.go @@ -0,0 +1,192 @@ +package plugins + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/plugins/host/scheduler" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SchedulerService", func() { + var ( + ss *schedulerService + manager *managerImpl + pluginName = "test_plugin" + ) + + BeforeEach(func() { + manager = createManager(nil, metrics.NewNoopInstance()) + ss = manager.schedulerService + }) + + Describe("One-time scheduling", func() { + It("schedules one-time jobs successfully", func() { + req := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 1, + Payload: []byte("test payload"), + ScheduleId: "test-job", + } + + resp, err := ss.scheduleOneTime(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).To(Equal("test-job")) + Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeTrue()) + Expect(ss.getScheduleType(pluginName + ":" + "test-job")).To(Equal(ScheduleTypeOneTime)) + + // Test auto-generated ID + req.ScheduleId = "" + resp, err = ss.scheduleOneTime(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).ToNot(BeEmpty()) + }) + + It("cancels one-time jobs successfully", func() { + req := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 10, + ScheduleId: "test-job", + } + + _, err := ss.scheduleOneTime(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + + cancelReq := &scheduler.CancelRequest{ + ScheduleId: "test-job", + } + + resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Success).To(BeTrue()) + Expect(ss.hasSchedule(pluginName + ":" + "test-job")).To(BeFalse()) + }) + }) + + Describe("Recurring scheduling", func() { + It("schedules recurring jobs successfully", func() { + req := &scheduler.ScheduleRecurringRequest{ + CronExpression: "* * * * *", // Every minute + Payload: []byte("test payload"), + ScheduleId: "test-cron", + } + + resp, err := ss.scheduleRecurring(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).To(Equal("test-cron")) + Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeTrue()) + Expect(ss.getScheduleType(pluginName + ":" + "test-cron")).To(Equal(ScheduleTypeRecurring)) + + // Test auto-generated ID + req.ScheduleId = "" + resp, err = ss.scheduleRecurring(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ScheduleId).ToNot(BeEmpty()) + }) + + It("cancels recurring jobs successfully", func() { + req := &scheduler.ScheduleRecurringRequest{ + CronExpression: "* * * * *", // Every minute + ScheduleId: "test-cron", + } + + _, err := ss.scheduleRecurring(context.Background(), pluginName, req) + Expect(err).ToNot(HaveOccurred()) + + cancelReq := &scheduler.CancelRequest{ + ScheduleId: "test-cron", + } + + resp, err := ss.cancelSchedule(context.Background(), pluginName, cancelReq) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Success).To(BeTrue()) + Expect(ss.hasSchedule(pluginName + ":" + "test-cron")).To(BeFalse()) + }) + }) + + Describe("Replace existing schedules", func() { + It("replaces one-time jobs with new ones", func() { + // Create first job + req1 := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 10, + Payload: []byte("test payload 1"), + ScheduleId: "replace-job", + } + _, err := ss.scheduleOneTime(context.Background(), pluginName, req1) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the initial job exists + scheduleId := pluginName + ":" + "replace-job" + Expect(ss.hasSchedule(scheduleId)).To(BeTrue(), "Initial schedule should exist") + + beforeCount := ss.scheduleCount() + + // Replace with second job using same ID + req2 := &scheduler.ScheduleOneTimeRequest{ + DelaySeconds: 60, // Use a longer delay to ensure it doesn't execute during the test + Payload: []byte("test payload 2"), + ScheduleId: "replace-job", + } + + _, err = ss.scheduleOneTime(context.Background(), pluginName, req2) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return ss.hasSchedule(scheduleId) + }).Should(BeTrue(), "Schedule should exist after replacement") + Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement") + }) + + It("replaces recurring jobs with new ones", func() { + // Create first job + req1 := &scheduler.ScheduleRecurringRequest{ + CronExpression: "0 * * * *", + Payload: []byte("test payload 1"), + ScheduleId: "replace-cron", + } + _, err := ss.scheduleRecurring(context.Background(), pluginName, req1) + Expect(err).ToNot(HaveOccurred()) + + beforeCount := ss.scheduleCount() + + // Replace with second job using same ID + req2 := &scheduler.ScheduleRecurringRequest{ + CronExpression: "*/5 * * * *", + Payload: []byte("test payload 2"), + ScheduleId: "replace-cron", + } + + _, err = ss.scheduleRecurring(context.Background(), pluginName, req2) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return ss.hasSchedule(pluginName + ":" + "replace-cron") + }).Should(BeTrue(), "Schedule should exist after replacement") + Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement") + }) + }) + + Describe("TimeNow", func() { + It("returns current time in RFC3339Nano, Unix milliseconds, and local timezone", func() { + now := time.Now() + req := &scheduler.TimeNowRequest{} + resp, err := ss.timeNow(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.UnixMilli).To(BeNumerically(">=", now.UnixMilli())) + Expect(resp.LocalTimeZone).ToNot(BeEmpty()) + + // Validate RFC3339Nano format can be parsed + parsedTime, parseErr := time.Parse(time.RFC3339Nano, resp.Rfc3339Nano) + Expect(parseErr).ToNot(HaveOccurred()) + + // Validate that Unix milliseconds is reasonably close to the RFC3339Nano time + expectedMillis := parsedTime.UnixMilli() + Expect(resp.UnixMilli).To(Equal(expectedMillis)) + + // Validate local timezone matches the current system timezone + expectedTimezone := now.Location().String() + Expect(resp.LocalTimeZone).To(Equal(expectedTimezone)) + }) + }) +}) diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go new file mode 100644 index 0000000..937dd04 --- /dev/null +++ b/plugins/host_subsonicapi.go @@ -0,0 +1,170 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "path" + "strings" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/server/subsonic" +) + +// SubsonicAPIService is the interface for the Subsonic API service +// +// Authentication: The plugin must provide valid authentication parameters in the URL: +// - Required: `u` (username) - The service validates this parameter is present +// - Example: `"/rest/ping?u=admin"` +// +// URL Format: Only the path and query parameters from the URL are used - host, protocol, and method are ignored +// +// Automatic Parameters: The service automatically adds: +// - `c`: Plugin name (client identifier) +// - `v`: Subsonic API version (1.16.1) +// - `f`: Response format (json) +// +// See example usage in the `plugins/examples/subsonicapi-demo` plugin +type subsonicAPIServiceImpl struct { + pluginID string + router SubsonicRouter + ds model.DataStore + permissions *subsonicAPIPermissions +} + +func newSubsonicAPIService(pluginID string, router *SubsonicRouter, ds model.DataStore, permissions *schema.PluginManifestPermissionsSubsonicapi) subsonicapi.SubsonicAPIService { + return &subsonicAPIServiceImpl{ + pluginID: pluginID, + router: *router, + ds: ds, + permissions: parseSubsonicAPIPermissions(permissions), + } +} + +func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.CallRequest) (*subsonicapi.CallResponse, error) { + if s.router == nil { + return &subsonicapi.CallResponse{ + Error: "SubsonicAPI router not available", + }, nil + } + + // Parse the input URL + parsedURL, err := url.Parse(req.Url) + if err != nil { + return &subsonicapi.CallResponse{ + Error: fmt.Sprintf("invalid URL format: %v", err), + }, nil + } + + // Extract query parameters + query := parsedURL.Query() + + // Validate that 'u' (username) parameter is present + username := query.Get("u") + if username == "" { + return &subsonicapi.CallResponse{ + Error: "missing required parameter 'u' (username)", + }, nil + } + + if err := s.checkPermissions(ctx, username); err != nil { + log.Warn(ctx, "SubsonicAPI call blocked by permissions", "plugin", s.pluginID, "user", username, err) + return &subsonicapi.CallResponse{Error: err.Error()}, nil + } + + // Add required Subsonic API parameters + query.Set("c", s.pluginID) // Client name (plugin ID) + query.Set("f", "json") // Response format + query.Set("v", subsonic.Version) // API version + + // 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 &subsonicapi.CallResponse{ + Error: fmt.Sprintf("failed to create HTTP request: %v", err), + }, nil + } + + // 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 the response body as JSON + return &subsonicapi.CallResponse{ + Json: recorder.Body.String(), + }, nil +} + +func (s *subsonicAPIServiceImpl) checkPermissions(ctx context.Context, username string) error { + if s.permissions == nil { + return nil + } + if len(s.permissions.AllowedUsernames) > 0 { + if _, ok := s.permissions.usernameMap[strings.ToLower(username)]; !ok { + return fmt.Errorf("username %s is not allowed", username) + } + } + if !s.permissions.AllowAdmins { + if s.router == nil { + return fmt.Errorf("permissions check failed: router not available") + } + usr, err := s.ds.User(ctx).FindByUsername(username) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("username %s not found", username) + } + return err + } + if usr.IsAdmin { + return fmt.Errorf("calling SubsonicAPI as admin user is not allowed") + } + } + return nil +} + +type subsonicAPIPermissions struct { + AllowedUsernames []string + AllowAdmins bool + usernameMap map[string]struct{} +} + +func parseSubsonicAPIPermissions(data *schema.PluginManifestPermissionsSubsonicapi) *subsonicAPIPermissions { + if data == nil { + return &subsonicAPIPermissions{} + } + perms := &subsonicAPIPermissions{ + AllowedUsernames: data.AllowedUsernames, + AllowAdmins: data.AllowAdmins, + usernameMap: make(map[string]struct{}), + } + for _, u := range data.AllowedUsernames { + perms.usernameMap[strings.ToLower(u)] = struct{}{} + } + return perms +} diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go new file mode 100644 index 0000000..a3161ff --- /dev/null +++ b/plugins/host_subsonicapi_test.go @@ -0,0 +1,218 @@ +package plugins + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SubsonicAPI Host Service", func() { + var ( + service *subsonicAPIServiceImpl + mockRouter http.Handler + userRepo *tests.MockedUserRepo + ) + + BeforeEach(func() { + // Setup mock datastore with users + userRepo = tests.CreateMockUserRepo() + _ = userRepo.Put(&model.User{UserName: "admin", IsAdmin: true}) + _ = userRepo.Put(&model.User{UserName: "user", IsAdmin: false}) + ds := &tests.MockDataStore{MockedUser: userRepo} + + // Create a mock router + mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"subsonic-response":{"status":"ok","version":"1.16.1"}}`)) + }) + + // Create service implementation + service = &subsonicAPIServiceImpl{ + pluginID: "test-plugin", + router: mockRouter, + ds: ds, + } + }) + + // Helper function to create a mock router that captures the request + setupRequestCapture := func() **http.Request { + var capturedRequest *http.Request + mockRouter = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRequest = r + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }) + service.router = mockRouter + return &capturedRequest + } + + Describe("Call", func() { + Context("when subsonic router is available", func() { + It("should process the request successfully", func() { + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(BeEmpty()) + Expect(resp.Json).To(ContainSubstring("subsonic-response")) + Expect(resp.Json).To(ContainSubstring("ok")) + }) + + It("should add required parameters to the URL", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "/rest/getAlbum.view?id=123&u=admin", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + + query := (*capturedRequestPtr).URL.Query() + Expect(query.Get("c")).To(Equal("test-plugin")) + Expect(query.Get("f")).To(Equal("json")) + Expect(query.Get("v")).To(Equal("1.16.1")) + Expect(query.Get("id")).To(Equal("123")) + Expect(query.Get("u")).To(Equal("admin")) + }) + + It("should only use path and query from the input URL", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "https://external.example.com:8080/rest/ping?u=admin", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping")) + Expect((*capturedRequestPtr).URL.Host).To(BeEmpty()) + Expect((*capturedRequestPtr).URL.Scheme).To(BeEmpty()) + }) + + It("ignores the path prefix in the URL", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "/basepath/rest/ping?u=admin", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + Expect((*capturedRequestPtr).URL.Path).To(Equal("/ping")) + }) + + It("should set internal authentication with username from 'u' parameter", func() { + capturedRequestPtr := setupRequestCapture() + + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?u=testuser", + } + + _, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(*capturedRequestPtr).ToNot(BeNil()) + + // Verify that internal authentication is set in the context + username, ok := request.InternalAuthFrom((*capturedRequestPtr).Context()) + Expect(ok).To(BeTrue()) + Expect(username).To(Equal("testuser")) + }) + }) + + Context("when subsonic router is not available", func() { + BeforeEach(func() { + service.router = nil + }) + + It("should return an error", func() { + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?u=admin", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(Equal("SubsonicAPI router not available")) + Expect(resp.Json).To(BeEmpty()) + }) + }) + + Context("when URL is invalid", func() { + It("should return an error for malformed URLs", func() { + req := &subsonicapi.CallRequest{ + Url: "://invalid-url", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(ContainSubstring("invalid URL format")) + Expect(resp.Json).To(BeEmpty()) + }) + + It("should return an error when 'u' parameter is missing", func() { + req := &subsonicapi.CallRequest{ + Url: "/rest/ping?p=password", + } + + resp, err := service.Call(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.Error).To(Equal("missing required parameter 'u' (username)")) + Expect(resp.Json).To(BeEmpty()) + }) + }) + + Context("permission checks", func() { + It("rejects disallowed username", func() { + service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{ + Reason: "test", + AllowedUsernames: []string{"user"}, + }) + + resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(ContainSubstring("not allowed")) + }) + + It("rejects admin when allowAdmins is false", func() { + service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test"}) + + resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(ContainSubstring("not allowed")) + }) + + It("allows admin when allowAdmins is true", func() { + service.permissions = parseSubsonicAPIPermissions(&schema.PluginManifestPermissionsSubsonicapi{Reason: "test", AllowAdmins: true}) + + resp, err := service.Call(context.Background(), &subsonicapi.CallRequest{Url: "/rest/ping?u=admin"}) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Error).To(BeEmpty()) + }) + }) + }) +}) diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go new file mode 100644 index 0000000..e90d136 --- /dev/null +++ b/plugins/host_websocket.go @@ -0,0 +1,400 @@ +package plugins + +import ( + "context" + "encoding/binary" + "fmt" + "strings" + "sync" + "time" + + gorillaws "github.com/gorilla/websocket" + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/websocket" +) + +// WebSocketConnection represents a WebSocket connection +type WebSocketConnection struct { + Conn *gorillaws.Conn + PluginName string + ConnectionID string + Done chan struct{} + mu sync.Mutex +} + +// WebSocketHostFunctions implements the websocket.WebSocketService interface +type WebSocketHostFunctions struct { + ws *websocketService + pluginID string + permissions *webSocketPermissions +} + +func (s WebSocketHostFunctions) Connect(ctx context.Context, req *websocket.ConnectRequest) (*websocket.ConnectResponse, error) { + return s.ws.connect(ctx, s.pluginID, req, s.permissions) +} + +func (s WebSocketHostFunctions) SendText(ctx context.Context, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) { + return s.ws.sendText(ctx, s.pluginID, req) +} + +func (s WebSocketHostFunctions) SendBinary(ctx context.Context, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) { + return s.ws.sendBinary(ctx, s.pluginID, req) +} + +func (s WebSocketHostFunctions) Close(ctx context.Context, req *websocket.CloseRequest) (*websocket.CloseResponse, error) { + return s.ws.close(ctx, s.pluginID, req) +} + +// websocketService implements the WebSocket service functionality +type websocketService struct { + connections map[string]*WebSocketConnection + manager *managerImpl + mu sync.RWMutex +} + +// newWebsocketService creates a new websocketService instance +func newWebsocketService(manager *managerImpl) *websocketService { + return &websocketService{ + connections: make(map[string]*WebSocketConnection), + manager: manager, + } +} + +// HostFunctions returns the WebSocketHostFunctions for the given plugin +func (s *websocketService) HostFunctions(pluginID string, permissions *webSocketPermissions) WebSocketHostFunctions { + return WebSocketHostFunctions{ + ws: s, + pluginID: pluginID, + permissions: permissions, + } +} + +// Safe accessor methods + +// hasConnection safely checks if a connection exists +func (s *websocketService) hasConnection(id string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + _, exists := s.connections[id] + return exists +} + +// connectionCount safely returns the number of connections +func (s *websocketService) connectionCount() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.connections) +} + +// getConnection safely retrieves a connection by internal ID +func (s *websocketService) getConnection(internalConnectionID string) (*WebSocketConnection, error) { + s.mu.RLock() + defer s.mu.RUnlock() + conn, exists := s.connections[internalConnectionID] + + if !exists { + return nil, fmt.Errorf("connection not found") + } + return conn, nil +} + +// internalConnectionID builds the internal connection ID from plugin and connection ID +func internalConnectionID(pluginName, connectionID string) string { + return pluginName + ":" + connectionID +} + +// extractConnectionID extracts the original connection ID from an internal ID +func extractConnectionID(internalID string) (string, error) { + parts := strings.Split(internalID, ":") + if len(parts) != 2 { + return "", fmt.Errorf("invalid internal connection ID format: %s", internalID) + } + return parts[1], nil +} + +// connect establishes a new WebSocket connection +func (s *websocketService) connect(ctx context.Context, pluginID string, req *websocket.ConnectRequest, permissions *webSocketPermissions) (*websocket.ConnectResponse, error) { + if s.manager == nil { + return nil, fmt.Errorf("websocket service not properly initialized") + } + + // Check permissions if they exist + if permissions != nil { + if err := permissions.IsConnectionAllowed(req.Url); err != nil { + log.Warn(ctx, "WebSocket connection blocked by permissions", "plugin", pluginID, "url", req.Url, err) + return &websocket.ConnectResponse{Error: "Connection blocked by plugin permissions: " + err.Error()}, nil + } + } + + // Create websocket dialer with the headers + dialer := gorillaws.DefaultDialer + header := make(map[string][]string) + for k, v := range req.Headers { + header[k] = []string{v} + } + + // Connect to the WebSocket server + conn, resp, err := dialer.DialContext(ctx, req.Url, header) + if err != nil { + return nil, fmt.Errorf("failed to connect to WebSocket server: %w", err) + } + defer resp.Body.Close() + + // Generate a connection ID + if req.ConnectionId == "" { + req.ConnectionId, _ = gonanoid.New(10) + } + connectionID := req.ConnectionId + internal := internalConnectionID(pluginID, connectionID) + + // Create the connection object + wsConn := &WebSocketConnection{ + Conn: conn, + PluginName: pluginID, + ConnectionID: connectionID, + Done: make(chan struct{}), + } + + // Store the connection + s.mu.Lock() + defer s.mu.Unlock() + s.connections[internal] = wsConn + + log.Debug("WebSocket connection established", "plugin", pluginID, "connectionID", connectionID, "url", req.Url) + + // Start the message handling goroutine + go s.handleMessages(internal, wsConn) + + return &websocket.ConnectResponse{ + ConnectionId: connectionID, + }, nil +} + +// writeMessage is a helper to send messages to a websocket connection +func (s *websocketService) writeMessage(pluginID string, connID string, messageType int, data []byte) error { + internal := internalConnectionID(pluginID, connID) + + conn, err := s.getConnection(internal) + if err != nil { + return err + } + + conn.mu.Lock() + defer conn.mu.Unlock() + + if err := conn.Conn.WriteMessage(messageType, data); err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil +} + +// sendText sends a text message over a WebSocket connection +func (s *websocketService) sendText(ctx context.Context, pluginID string, req *websocket.SendTextRequest) (*websocket.SendTextResponse, error) { + if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.TextMessage, []byte(req.Message)); err != nil { + return &websocket.SendTextResponse{Error: err.Error()}, nil //nolint:nilerr + } + return &websocket.SendTextResponse{}, nil +} + +// sendBinary sends binary data over a WebSocket connection +func (s *websocketService) sendBinary(ctx context.Context, pluginID string, req *websocket.SendBinaryRequest) (*websocket.SendBinaryResponse, error) { + if err := s.writeMessage(pluginID, req.ConnectionId, gorillaws.BinaryMessage, req.Data); err != nil { + return &websocket.SendBinaryResponse{Error: err.Error()}, nil //nolint:nilerr + } + return &websocket.SendBinaryResponse{}, nil +} + +// close closes a WebSocket connection +func (s *websocketService) close(ctx context.Context, pluginID string, req *websocket.CloseRequest) (*websocket.CloseResponse, error) { + internal := internalConnectionID(pluginID, req.ConnectionId) + + s.mu.Lock() + conn, exists := s.connections[internal] + if !exists { + s.mu.Unlock() + return &websocket.CloseResponse{Error: "connection not found"}, nil + } + delete(s.connections, internal) + s.mu.Unlock() + + // Signal the message handling goroutine to stop + close(conn.Done) + + // Close the connection with the specified code and reason + conn.mu.Lock() + defer conn.mu.Unlock() + + err := conn.Conn.WriteControl( + gorillaws.CloseMessage, + gorillaws.FormatCloseMessage(int(req.Code), req.Reason), + time.Now().Add(time.Second), + ) + if err != nil { + log.Error("Error sending close message", "plugin", pluginID, "error", err) + } + + if err := conn.Conn.Close(); err != nil { + return nil, fmt.Errorf("error closing connection: %w", err) + } + + log.Debug("WebSocket connection closed", "plugin", pluginID, "connectionID", req.ConnectionId) + return &websocket.CloseResponse{}, nil +} + +// handleMessages processes incoming WebSocket messages +func (s *websocketService) handleMessages(internalID string, conn *WebSocketConnection) { + // Get the original connection ID (without plugin prefix) + connectionID, err := extractConnectionID(internalID) + if err != nil { + log.Error("Invalid internal connection ID", "id", internalID, "error", err) + return + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + defer func() { + // Ensure the connection is removed from the map if not already removed + s.mu.Lock() + defer s.mu.Unlock() + delete(s.connections, internalID) + + log.Debug("WebSocket message handler stopped", "plugin", conn.PluginName, "connectionID", connectionID) + }() + + // Add connection info to context + ctx = log.NewContext(ctx, + "connectionID", connectionID, + "plugin", conn.PluginName, + ) + + for { + select { + case <-conn.Done: + // Connection was closed by a Close call + return + default: + // Set a read deadline + _ = conn.Conn.SetReadDeadline(time.Now().Add(time.Second * 60)) + + // Read the next message + messageType, message, err := conn.Conn.ReadMessage() + if err != nil { + s.notifyErrorCallback(ctx, connectionID, conn, err.Error()) + return + } + + // Reset the read deadline + _ = conn.Conn.SetReadDeadline(time.Time{}) + + // Process the message based on its type + switch messageType { + case gorillaws.TextMessage: + s.notifyTextCallback(ctx, connectionID, conn, string(message)) + case gorillaws.BinaryMessage: + s.notifyBinaryCallback(ctx, connectionID, conn, message) + case gorillaws.CloseMessage: + code := gorillaws.CloseNormalClosure + reason := "" + if len(message) >= 2 { + code = int(binary.BigEndian.Uint16(message[:2])) + if len(message) > 2 { + reason = string(message[2:]) + } + } + s.notifyCloseCallback(ctx, connectionID, conn, code, reason) + return + } + } + } +} + +// executeCallback is a common function that handles the plugin loading and execution +// for all types of callbacks +func (s *websocketService) executeCallback(ctx context.Context, pluginID, methodName string, fn func(context.Context, api.WebSocketCallback) error) { + log.Debug(ctx, "WebSocket received") + + start := time.Now() + + // Get the plugin + p := s.manager.LoadPlugin(pluginID, CapabilityWebSocketCallback) + if p == nil { + log.Error(ctx, "Plugin not found for WebSocket callback") + return + } + + _, _ = callMethod(ctx, p, methodName, func(inst api.WebSocketCallback) (struct{}, error) { + // Call the appropriate callback function + log.Trace(ctx, "Executing WebSocket callback") + if err := fn(ctx, inst); err != nil { + log.Error(ctx, "Error executing WebSocket callback", "elapsed", time.Since(start), err) + return struct{}{}, fmt.Errorf("error executing WebSocket callback: %w", err) + } + log.Debug(ctx, "WebSocket callback executed", "elapsed", time.Since(start)) + return struct{}{}, nil + }) +} + +// notifyTextCallback notifies the plugin of a text message +func (s *websocketService) notifyTextCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, message string) { + req := &api.OnTextMessageRequest{ + ConnectionId: connectionID, + Message: message, + } + + ctx = log.NewContext(ctx, "callback", "OnTextMessage", "size", len(message)) + + s.executeCallback(ctx, conn.PluginName, "OnTextMessage", func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := checkErr(plugin.OnTextMessage(ctx, req)) + return err + }) +} + +// notifyBinaryCallback notifies the plugin of a binary message +func (s *websocketService) notifyBinaryCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, data []byte) { + req := &api.OnBinaryMessageRequest{ + ConnectionId: connectionID, + Data: data, + } + + ctx = log.NewContext(ctx, "callback", "OnBinaryMessage", "size", len(data)) + + s.executeCallback(ctx, conn.PluginName, "OnBinaryMessage", func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := checkErr(plugin.OnBinaryMessage(ctx, req)) + return err + }) +} + +// notifyErrorCallback notifies the plugin of an error +func (s *websocketService) notifyErrorCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, errorMsg string) { + req := &api.OnErrorRequest{ + ConnectionId: connectionID, + Error: errorMsg, + } + + ctx = log.NewContext(ctx, "callback", "OnError", "error", errorMsg) + + s.executeCallback(ctx, conn.PluginName, "OnError", func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := checkErr(plugin.OnError(ctx, req)) + return err + }) +} + +// notifyCloseCallback notifies the plugin that the connection was closed +func (s *websocketService) notifyCloseCallback(ctx context.Context, connectionID string, conn *WebSocketConnection, code int, reason string) { + req := &api.OnCloseRequest{ + ConnectionId: connectionID, + Code: int32(code), + Reason: reason, + } + + ctx = log.NewContext(ctx, "callback", "OnClose", "code", code, "reason", reason) + + s.executeCallback(ctx, conn.PluginName, "OnClose", func(ctx context.Context, plugin api.WebSocketCallback) error { + _, err := checkErr(plugin.OnClose(ctx, req)) + return err + }) +} diff --git a/plugins/host_websocket_permissions.go b/plugins/host_websocket_permissions.go new file mode 100644 index 0000000..53f6a12 --- /dev/null +++ b/plugins/host_websocket_permissions.go @@ -0,0 +1,76 @@ +package plugins + +import ( + "fmt" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// WebSocketPermissions represents granular WebSocket access permissions for plugins +type webSocketPermissions struct { + *networkPermissionsBase + AllowedUrls []string `json:"allowedUrls"` + matcher *urlMatcher +} + +// parseWebSocketPermissions extracts WebSocket permissions from the schema +func parseWebSocketPermissions(permData *schema.PluginManifestPermissionsWebsocket) (*webSocketPermissions, error) { + if len(permData.AllowedUrls) == 0 { + return nil, fmt.Errorf("allowedUrls must contain at least one URL pattern") + } + + return &webSocketPermissions{ + networkPermissionsBase: &networkPermissionsBase{ + AllowLocalNetwork: permData.AllowLocalNetwork, + }, + AllowedUrls: permData.AllowedUrls, + matcher: newURLMatcher(), + }, nil +} + +// IsConnectionAllowed checks if a WebSocket connection is allowed +func (w *webSocketPermissions) IsConnectionAllowed(requestURL string) error { + if _, err := checkURLPolicy(requestURL, w.AllowLocalNetwork); err != nil { + return err + } + + // allowedUrls is required - no fallback to allow all URLs + if len(w.AllowedUrls) == 0 { + return fmt.Errorf("no allowed URLs configured for plugin") + } + + // Check URL patterns + // First try exact matches, then wildcard matches + + // Phase 1: Check for exact matches first + for _, urlPattern := range w.AllowedUrls { + if urlPattern == "*" || (!containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern)) { + return nil + } + } + + // Phase 2: Check wildcard patterns + for _, urlPattern := range w.AllowedUrls { + if containsWildcard(urlPattern) && w.matcher.MatchesURLPattern(requestURL, urlPattern) { + return nil + } + } + + return fmt.Errorf("URL %s does not match any allowed URL patterns", requestURL) +} + +// containsWildcard checks if a URL pattern contains wildcard characters +func containsWildcard(pattern string) bool { + if pattern == "*" { + return true + } + + // Check for wildcards anywhere in the pattern + for _, char := range pattern { + if char == '*' { + return true + } + } + + return false +} diff --git a/plugins/host_websocket_permissions_test.go b/plugins/host_websocket_permissions_test.go new file mode 100644 index 0000000..e794ca6 --- /dev/null +++ b/plugins/host_websocket_permissions_test.go @@ -0,0 +1,79 @@ +package plugins + +import ( + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("WebSocket Permissions", func() { + Describe("parseWebSocketPermissions", func() { + It("should parse valid WebSocket permissions", func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to WebSocket API", + AllowLocalNetwork: false, + AllowedUrls: []string{"wss://api.example.com/ws", "wss://cdn.example.com/*"}, + } + + perms, err := parseWebSocketPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms).ToNot(BeNil()) + Expect(perms.AllowLocalNetwork).To(BeFalse()) + Expect(perms.AllowedUrls).To(Equal([]string{"wss://api.example.com/ws", "wss://cdn.example.com/*"})) + }) + + It("should fail if allowedUrls is empty", func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to WebSocket API", + AllowLocalNetwork: false, + AllowedUrls: []string{}, + } + + _, err := parseWebSocketPermissions(permData) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("allowedUrls must contain at least one URL pattern")) + }) + + It("should handle wildcard patterns", func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to any WebSocket", + AllowLocalNetwork: true, + AllowedUrls: []string{"wss://*"}, + } + + perms, err := parseWebSocketPermissions(permData) + Expect(err).To(BeNil()) + Expect(perms.AllowLocalNetwork).To(BeTrue()) + Expect(perms.AllowedUrls).To(Equal([]string{"wss://*"})) + }) + + Context("URL matching", func() { + var perms *webSocketPermissions + + BeforeEach(func() { + permData := &schema.PluginManifestPermissionsWebsocket{ + Reason: "Need to connect to external services", + AllowLocalNetwork: true, + AllowedUrls: []string{"wss://api.example.com/*", "ws://localhost:8080"}, + } + var err error + perms, err = parseWebSocketPermissions(permData) + Expect(err).To(BeNil()) + }) + + It("should allow connections to URLs matching patterns", func() { + err := perms.IsConnectionAllowed("wss://api.example.com/v1/stream") + Expect(err).To(BeNil()) + + err = perms.IsConnectionAllowed("ws://localhost:8080") + Expect(err).To(BeNil()) + }) + + It("should deny connections to URLs not matching patterns", func() { + err := perms.IsConnectionAllowed("wss://malicious.com/stream") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("does not match any allowed URL patterns")) + }) + }) + }) +}) diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go new file mode 100644 index 0000000..ecadc64 --- /dev/null +++ b/plugins/host_websocket_test.go @@ -0,0 +1,231 @@ +package plugins + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + gorillaws "github.com/gorilla/websocket" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/plugins/host/websocket" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("WebSocket Host Service", func() { + var ( + wsService *websocketService + manager *managerImpl + ctx context.Context + server *httptest.Server + upgrader gorillaws.Upgrader + serverMessages []string + serverMu sync.Mutex + ) + + // WebSocket echo server handler + echoHandler := func(w http.ResponseWriter, r *http.Request) { + // Check headers + if r.Header.Get("X-Test-Header") != "test-value" { + http.Error(w, "Missing or invalid X-Test-Header", http.StatusBadRequest) + return + } + + // Upgrade connection to WebSocket + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + + // Echo messages back + for { + mt, message, err := conn.ReadMessage() + if err != nil { + break + } + + // Store the received message for verification + if mt == gorillaws.TextMessage { + msg := string(message) + serverMu.Lock() + serverMessages = append(serverMessages, msg) + serverMu.Unlock() + } + + // Echo it back + err = conn.WriteMessage(mt, message) + if err != nil { + break + } + + // If message is "close", close the connection + if mt == gorillaws.TextMessage && string(message) == "close" { + _ = conn.WriteControl( + gorillaws.CloseMessage, + gorillaws.FormatCloseMessage(gorillaws.CloseNormalClosure, "bye"), + time.Now().Add(time.Second), + ) + break + } + } + } + + BeforeEach(func() { + ctx = context.Background() + serverMessages = make([]string, 0) + serverMu = sync.Mutex{} + + // Create a test WebSocket server + //upgrader = gorillaws.Upgrader{} + server = httptest.NewServer(http.HandlerFunc(echoHandler)) + DeferCleanup(server.Close) + + // Create a new manager and websocket service + manager = createManager(nil, metrics.NewNoopInstance()) + wsService = newWebsocketService(manager) + }) + + Describe("WebSocket operations", func() { + var ( + pluginName string + connectionID string + wsURL string + ) + + BeforeEach(func() { + pluginName = "test-plugin" + connectionID = "test-connection-id" + wsURL = "ws" + strings.TrimPrefix(server.URL, "http") + }) + + It("connects to a WebSocket server", func() { + // Connect to the WebSocket server + req := &websocket.ConnectRequest{ + Url: wsURL, + Headers: map[string]string{ + "X-Test-Header": "test-value", + }, + ConnectionId: connectionID, + } + + resp, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.ConnectionId).ToNot(BeEmpty()) + connectionID = resp.ConnectionId + + // Verify that the connection was added to the service + internalID := pluginName + ":" + connectionID + Expect(wsService.hasConnection(internalID)).To(BeTrue()) + }) + + It("sends and receives text messages", func() { + // Connect to the WebSocket server + req := &websocket.ConnectRequest{ + Url: wsURL, + Headers: map[string]string{ + "X-Test-Header": "test-value", + }, + ConnectionId: connectionID, + } + + resp, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).ToNot(HaveOccurred()) + connectionID = resp.ConnectionId + + // Send a text message + textReq := &websocket.SendTextRequest{ + ConnectionId: connectionID, + Message: "hello websocket", + } + + _, err = wsService.sendText(ctx, pluginName, textReq) + Expect(err).ToNot(HaveOccurred()) + + // Wait a bit for the message to be processed + Eventually(func() []string { + serverMu.Lock() + defer serverMu.Unlock() + return serverMessages + }, "1s").Should(ContainElement("hello websocket")) + }) + + It("closes a WebSocket connection", func() { + // Connect to the WebSocket server + req := &websocket.ConnectRequest{ + Url: wsURL, + Headers: map[string]string{ + "X-Test-Header": "test-value", + }, + ConnectionId: connectionID, + } + + resp, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).ToNot(HaveOccurred()) + connectionID = resp.ConnectionId + + initialCount := wsService.connectionCount() + + // Close the connection + closeReq := &websocket.CloseRequest{ + ConnectionId: connectionID, + Code: 1000, // Normal closure + Reason: "test complete", + } + + _, err = wsService.close(ctx, pluginName, closeReq) + Expect(err).ToNot(HaveOccurred()) + + // Verify that the connection was removed + Eventually(func() int { + return wsService.connectionCount() + }, "1s").Should(Equal(initialCount - 1)) + + internalID := pluginName + ":" + connectionID + Expect(wsService.hasConnection(internalID)).To(BeFalse()) + }) + + It("handles connection errors gracefully", func() { + if testing.Short() { + GinkgoT().Skip("skipping test in short mode.") + } + + // Try to connect to an invalid URL + req := &websocket.ConnectRequest{ + Url: "ws://invalid-url-that-does-not-exist", + Headers: map[string]string{}, + ConnectionId: connectionID, + } + + _, err := wsService.connect(ctx, pluginName, req, nil) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when attempting to use non-existent connection", func() { + // Try to send a message to a non-existent connection + textReq := &websocket.SendTextRequest{ + ConnectionId: "non-existent-connection", + Message: "this should fail", + } + + sendResp, err := wsService.sendText(ctx, pluginName, textReq) + Expect(err).ToNot(HaveOccurred()) + Expect(sendResp.Error).To(ContainSubstring("connection not found")) + + // Try to close a non-existent connection + closeReq := &websocket.CloseRequest{ + ConnectionId: "non-existent-connection", + Code: 1000, + Reason: "test complete", + } + + closeResp, err := wsService.close(ctx, pluginName, closeReq) + Expect(err).ToNot(HaveOccurred()) + Expect(closeResp.Error).To(ContainSubstring("connection not found")) + }) + }) +}) diff --git a/plugins/manager.go b/plugins/manager.go new file mode 100644 index 0000000..35a1130 --- /dev/null +++ b/plugins/manager.go @@ -0,0 +1,421 @@ +package plugins + +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative api/api.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/http/http.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/config/config.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/websocket/websocket.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/scheduler/scheduler.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/cache/cache.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/artwork/artwork.proto +//go:generate protoc --go-plugin_out=. --go-plugin_opt=paths=source_relative host/subsonicapi/subsonicapi.proto + +import ( + "fmt" + "net/http" + "os" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/navidrome/navidrome/utils/singleton" + "github.com/navidrome/navidrome/utils/slice" + "github.com/tetratelabs/wazero" +) + +const ( + CapabilityMetadataAgent = "MetadataAgent" + CapabilityScrobbler = "Scrobbler" + CapabilitySchedulerCallback = "SchedulerCallback" + CapabilityWebSocketCallback = "WebSocketCallback" + CapabilityLifecycleManagement = "LifecycleManagement" +) + +// pluginCreators maps capability types to their respective creator functions +type pluginConstructor func(wasmPath, pluginID string, m *managerImpl, runtime api.WazeroNewRuntime, mc wazero.ModuleConfig) WasmPlugin + +var pluginCreators = map[string]pluginConstructor{ + CapabilityMetadataAgent: newWasmMediaAgent, + CapabilityScrobbler: newWasmScrobblerPlugin, + CapabilitySchedulerCallback: newWasmSchedulerCallback, + CapabilityWebSocketCallback: newWasmWebSocketCallback, +} + +// WasmPlugin is the base interface that all WASM plugins implement +type WasmPlugin interface { + // PluginID returns the unique identifier of the plugin (folder name) + PluginID() string +} + +type plugin struct { + ID string + Path string + Capabilities []string + WasmPath string + Manifest *schema.PluginManifest // Loaded manifest + Runtime api.WazeroNewRuntime + ModConfig wazero.ModuleConfig + compilationReady chan struct{} + compilationErr error +} + +func (p *plugin) waitForCompilation() error { + timeout := pluginCompilationTimeout() + select { + case <-p.compilationReady: + case <-time.After(timeout): + err := fmt.Errorf("timed out waiting for plugin %s to compile", p.ID) + log.Error("Timed out waiting for plugin compilation", "name", p.ID, "path", p.WasmPath, "timeout", timeout, "err", err) + return err + } + if p.compilationErr != nil { + log.Error("Failed to compile plugin", "name", p.ID, "path", p.WasmPath, p.compilationErr) + } + return p.compilationErr +} + +type SubsonicRouter http.Handler + +type Manager interface { + SetSubsonicRouter(router SubsonicRouter) + EnsureCompiled(name string) error + PluginList() map[string]schema.PluginManifest + PluginNames(capability string) []string + LoadPlugin(name string, capability string) WasmPlugin + LoadMediaAgent(name string) (agents.Interface, bool) + LoadScrobbler(name string) (scrobbler.Scrobbler, bool) + ScanPlugins() +} + +// managerImpl is a singleton that manages plugins +type managerImpl struct { + plugins map[string]*plugin // Map of plugin folder name to plugin info + pluginsMu sync.RWMutex // Protects plugins map + subsonicRouter atomic.Pointer[SubsonicRouter] // Subsonic API router + schedulerService *schedulerService // Service for handling scheduled tasks + websocketService *websocketService // Service for handling WebSocket connections + lifecycle *pluginLifecycleManager // Manages plugin lifecycle and initialization + adapters map[string]WasmPlugin // Map of plugin folder name + capability to adapter + ds model.DataStore // DataStore for accessing persistent data + metrics metrics.Metrics +} + +// GetManager returns the singleton instance of managerImpl +func GetManager(ds model.DataStore, metrics metrics.Metrics) Manager { + if !conf.Server.Plugins.Enabled { + return &noopManager{} + } + return singleton.GetInstance(func() *managerImpl { + return createManager(ds, metrics) + }) +} + +// createManager creates a new managerImpl instance. Used in tests +func createManager(ds model.DataStore, metrics metrics.Metrics) *managerImpl { + m := &managerImpl{ + plugins: make(map[string]*plugin), + lifecycle: newPluginLifecycleManager(metrics), + ds: ds, + metrics: metrics, + } + + // Create the host services + m.schedulerService = newSchedulerService(m) + m.websocketService = newWebsocketService(m) + + return m +} + +// SetSubsonicRouter sets the SubsonicRouter after managerImpl initialization +func (m *managerImpl) SetSubsonicRouter(router SubsonicRouter) { + m.subsonicRouter.Store(&router) +} + +// registerPlugin adds a plugin to the registry with the given parameters +// Used internally by ScanPlugins to register plugins +func (m *managerImpl) registerPlugin(pluginID, pluginDir, wasmPath string, manifest *schema.PluginManifest) *plugin { + // Create custom runtime function + customRuntime := m.createRuntime(pluginID, manifest.Permissions) + + // Configure module and determine plugin name + mc := newWazeroModuleConfig() + + // Check if it's a symlink, indicating development mode + isSymlink := false + if fileInfo, err := os.Lstat(pluginDir); err == nil { + isSymlink = fileInfo.Mode()&os.ModeSymlink != 0 + } + + // Store plugin info + p := &plugin{ + ID: pluginID, + Path: pluginDir, + Capabilities: slice.Map(manifest.Capabilities, func(cap schema.PluginManifestCapabilitiesElem) string { return string(cap) }), + WasmPath: wasmPath, + Manifest: manifest, + Runtime: customRuntime, + ModConfig: mc, + compilationReady: make(chan struct{}), + } + + // Register the plugin first + m.pluginsMu.Lock() + m.plugins[pluginID] = p + + // Register one plugin adapter for each capability + for _, capability := range manifest.Capabilities { + capabilityStr := string(capability) + constructor := pluginCreators[capabilityStr] + if constructor == nil { + // Warn about unknown capabilities, except for LifecycleManagement (it does not have an adapter) + if capability != CapabilityLifecycleManagement { + log.Warn("Unknown plugin capability type", "capability", capability, "plugin", pluginID) + } + continue + } + adapter := constructor(wasmPath, pluginID, m, customRuntime, mc) + if adapter == nil { + log.Error("Failed to create plugin adapter", "plugin", pluginID, "capability", capabilityStr, "path", wasmPath) + continue + } + m.adapters[pluginID+"_"+capabilityStr] = adapter + } + m.pluginsMu.Unlock() + + log.Info("Discovered plugin", "folder", pluginID, "name", manifest.Name, "capabilities", manifest.Capabilities, "wasm", wasmPath, "dev_mode", isSymlink) + return m.plugins[pluginID] +} + +// initializePluginIfNeeded calls OnInit on plugins that implement LifecycleManagement +func (m *managerImpl) initializePluginIfNeeded(plugin *plugin) { + // Skip if already initialized + if m.lifecycle.isInitialized(plugin) { + return + } + + // Check if the plugin implements LifecycleManagement + if slices.Contains(plugin.Manifest.Capabilities, CapabilityLifecycleManagement) { + if err := m.lifecycle.callOnInit(plugin); err != nil { + m.unregisterPlugin(plugin.ID) + } + } +} + +// unregisterPlugin removes a plugin from the manager +func (m *managerImpl) unregisterPlugin(pluginID string) { + m.pluginsMu.Lock() + defer m.pluginsMu.Unlock() + + plugin, ok := m.plugins[pluginID] + if !ok { + return + } + + // Clear initialization state from lifecycle manager + m.lifecycle.clearInitialized(plugin) + + // Unregister plugin adapters + for _, capability := range plugin.Manifest.Capabilities { + delete(m.adapters, pluginID+"_"+string(capability)) + } + + // Unregister plugin + delete(m.plugins, pluginID) + log.Info("Unregistered plugin", "plugin", pluginID) +} + +// ScanPlugins scans the plugins directory, discovers all valid plugins, and registers them for use. +func (m *managerImpl) ScanPlugins() { + // Clear existing plugins + m.pluginsMu.Lock() + m.plugins = make(map[string]*plugin) + m.adapters = make(map[string]WasmPlugin) + m.pluginsMu.Unlock() + + // Get plugins directory from config + root := conf.Server.Plugins.Folder + log.Debug("Scanning plugins folder", "root", root) + + // Fail fast if the compilation cache cannot be initialized + _, err := getCompilationCache() + if err != nil { + log.Error("Failed to initialize plugins compilation cache. Disabling plugins", err) + return + } + + // Discover all plugins using the shared discovery function + discoveries := DiscoverPlugins(root) + + var validPluginNames []string + var registeredPlugins []*plugin + for _, discovery := range discoveries { + if discovery.Error != nil { + // Handle global errors (like directory read failure) + if discovery.ID == "" { + log.Error("Plugin discovery failed", discovery.Error) + return + } + // Handle individual plugin errors + log.Error("Failed to process plugin", "plugin", discovery.ID, discovery.Error) + continue + } + + // Log discovery details + log.Debug("Processing entry", "name", discovery.ID, "isSymlink", discovery.IsSymlink) + if discovery.IsSymlink { + log.Debug("Processing symlinked plugin directory", "name", discovery.ID, "target", discovery.Path) + } + log.Debug("Checking for plugin.wasm", "wasmPath", discovery.WasmPath) + log.Debug("Manifest loaded successfully", "folder", discovery.ID, "name", discovery.Manifest.Name, "capabilities", discovery.Manifest.Capabilities) + + validPluginNames = append(validPluginNames, discovery.ID) + + // Register the plugin + plugin := m.registerPlugin(discovery.ID, discovery.Path, discovery.WasmPath, discovery.Manifest) + if plugin != nil { + registeredPlugins = append(registeredPlugins, plugin) + } + } + + // Start background processing for all registered plugins after registration is complete + // This avoids race conditions between registration and goroutines that might unregister plugins + for _, p := range registeredPlugins { + go func(plugin *plugin) { + precompilePlugin(plugin) + // Check if this plugin implements InitService and hasn't been initialized yet + m.initializePluginIfNeeded(plugin) + }(p) + } + + log.Debug("Found valid plugins", "count", len(validPluginNames), "plugins", validPluginNames) +} + +// PluginList returns a map of all registered plugins with their manifests +func (m *managerImpl) PluginList() map[string]schema.PluginManifest { + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() + + // Create a map to hold the plugin manifests + pluginList := make(map[string]schema.PluginManifest, len(m.plugins)) + for name, plugin := range m.plugins { + // Use the plugin ID as the key and the manifest as the value + pluginList[name] = *plugin.Manifest + } + return pluginList +} + +// PluginNames returns the folder names of all plugins that implement the specified capability +func (m *managerImpl) PluginNames(capability string) []string { + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() + + var names []string + for name, plugin := range m.plugins { + for _, c := range plugin.Manifest.Capabilities { + if string(c) == capability { + names = append(names, name) + break + } + } + } + return names +} + +func (m *managerImpl) getPlugin(name string, capability string) (*plugin, WasmPlugin, error) { + m.pluginsMu.RLock() + defer m.pluginsMu.RUnlock() + info, infoOk := m.plugins[name] + adapter, adapterOk := m.adapters[name+"_"+capability] + + if !infoOk { + return nil, nil, fmt.Errorf("plugin not registered: %s", name) + } + if !adapterOk { + return nil, nil, fmt.Errorf("plugin adapter not registered: %s, capability: %s", name, capability) + } + return info, adapter, nil +} + +// LoadPlugin instantiates and returns a plugin by folder name +func (m *managerImpl) LoadPlugin(name string, capability string) WasmPlugin { + info, adapter, err := m.getPlugin(name, capability) + if err != nil { + log.Warn("Error loading plugin", err) + return nil + } + + log.Debug("Loading plugin", "name", name, "path", info.Path) + + // Wait for the plugin to be ready before using it. + if err := info.waitForCompilation(); err != nil { + log.Error("Plugin is not ready, cannot be loaded", "plugin", name, "capability", capability, "err", err) + return nil + } + + if adapter == nil { + log.Warn("Plugin adapter not found", "name", name, "capability", capability) + return nil + } + return adapter +} + +// EnsureCompiled waits for a plugin to finish compilation and returns any compilation error. +// This is useful when you need to wait for compilation without loading a specific capability, +// such as during plugin refresh operations or health checks. +func (m *managerImpl) EnsureCompiled(name string) error { + m.pluginsMu.RLock() + plugin, ok := m.plugins[name] + m.pluginsMu.RUnlock() + + if !ok { + return fmt.Errorf("plugin not found: %s", name) + } + + return plugin.waitForCompilation() +} + +// LoadMediaAgent instantiates and returns a media agent plugin by folder name +func (m *managerImpl) LoadMediaAgent(name string) (agents.Interface, bool) { + plugin := m.LoadPlugin(name, CapabilityMetadataAgent) + if plugin == nil { + return nil, false + } + agent, ok := plugin.(*wasmMediaAgent) + return agent, ok +} + +// LoadScrobbler instantiates and returns a scrobbler plugin by folder name +func (m *managerImpl) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { + plugin := m.LoadPlugin(name, CapabilityScrobbler) + if plugin == nil { + return nil, false + } + s, ok := plugin.(scrobbler.Scrobbler) + return s, ok +} + +type noopManager struct{} + +func (n noopManager) SetSubsonicRouter(router SubsonicRouter) {} + +func (n noopManager) EnsureCompiled(name string) error { return nil } + +func (n noopManager) PluginList() map[string]schema.PluginManifest { return nil } + +func (n noopManager) PluginNames(capability string) []string { return nil } + +func (n noopManager) LoadPlugin(name string, capability string) WasmPlugin { return nil } + +func (n noopManager) LoadMediaAgent(name string) (agents.Interface, bool) { return nil, false } + +func (n noopManager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { return nil, false } + +func (n noopManager) ScanPlugins() {} diff --git a/plugins/manager_test.go b/plugins/manager_test.go new file mode 100644 index 0000000..8b361f8 --- /dev/null +++ b/plugins/manager_test.go @@ -0,0 +1,367 @@ +package plugins + +import ( + "context" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Plugin Manager", func() { + var mgr *managerImpl + var ctx context.Context + + BeforeEach(func() { + // We change the plugins folder to random location to avoid conflicts with other tests, + // but, as this is an integration test, we can't use configtest.SetupConfig() as it causes + // data races. + originalPluginsFolder := conf.Server.Plugins.Folder + originalTimeout := conf.Server.DevPluginCompilationTimeout + conf.Server.DevPluginCompilationTimeout = 2 * time.Minute + DeferCleanup(func() { + conf.Server.Plugins.Folder = originalPluginsFolder + conf.Server.DevPluginCompilationTimeout = originalTimeout + }) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = testDataDir + + ctx = GinkgoT().Context() + mgr = createManager(nil, metrics.NewNoopInstance()) + mgr.ScanPlugins() + + // Wait for all plugins to compile to avoid race conditions + err := mgr.EnsureCompiled("fake_artist_agent") + Expect(err).NotTo(HaveOccurred(), "fake_artist_agent should compile successfully") + err = mgr.EnsureCompiled("fake_album_agent") + Expect(err).NotTo(HaveOccurred(), "fake_album_agent should compile successfully") + err = mgr.EnsureCompiled("multi_plugin") + Expect(err).NotTo(HaveOccurred(), "multi_plugin should compile successfully") + err = mgr.EnsureCompiled("unauthorized_plugin") + Expect(err).NotTo(HaveOccurred(), "unauthorized_plugin should compile successfully") + }) + + It("should scan and discover plugins from the testdata folder", func() { + Expect(mgr).NotTo(BeNil()) + + mediaAgentNames := mgr.PluginNames("MetadataAgent") + Expect(mediaAgentNames).To(HaveLen(4)) + Expect(mediaAgentNames).To(ContainElements( + "fake_artist_agent", + "fake_album_agent", + "multi_plugin", + "unauthorized_plugin", + )) + + scrobblerNames := mgr.PluginNames("Scrobbler") + Expect(scrobblerNames).To(ContainElement("fake_scrobbler")) + + initServiceNames := mgr.PluginNames("LifecycleManagement") + Expect(initServiceNames).To(ContainElements("multi_plugin", "fake_init_service")) + + schedulerCallbackNames := mgr.PluginNames("SchedulerCallback") + Expect(schedulerCallbackNames).To(ContainElement("multi_plugin")) + }) + + It("should load all plugins from folder", func() { + all := mgr.PluginList() + Expect(all).To(HaveLen(6)) + Expect(all["fake_artist_agent"].Name).To(Equal("fake_artist_agent")) + Expect(all["unauthorized_plugin"].Capabilities).To(HaveExactElements(schema.PluginManifestCapabilitiesElem("MetadataAgent"))) + }) + + It("should load a MetadataAgent plugin and invoke artist-related methods", func() { + plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent) + Expect(plugin).NotTo(BeNil()) + + agent, ok := plugin.(agents.Interface) + Expect(ok).To(BeTrue(), "plugin should implement agents.Interface") + Expect(agent.AgentName()).To(Equal("fake_artist_agent")) + + mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever) + Expect(ok).To(BeTrue()) + mbid, err := mbidRetriever.GetArtistMBID(ctx, "123", "The Beatles") + Expect(err).NotTo(HaveOccurred()) + Expect(mbid).To(Equal("1234567890")) + }) + + It("should load all MetadataAgent plugins", func() { + mediaAgentNames := mgr.PluginNames("MetadataAgent") + Expect(mediaAgentNames).To(HaveLen(4)) + + var agentNames []string + for _, name := range mediaAgentNames { + agent, ok := mgr.LoadMediaAgent(name) + if ok { + agentNames = append(agentNames, agent.AgentName()) + } + } + + Expect(agentNames).To(ContainElements("fake_artist_agent", "fake_album_agent", "multi_plugin", "unauthorized_plugin")) + }) + + Describe("ScanPlugins", func() { + var tempPluginsDir string + var m *managerImpl + + BeforeEach(func() { + tempPluginsDir, _ = os.MkdirTemp("", "navidrome-plugins-test-*") + DeferCleanup(func() { + _ = os.RemoveAll(tempPluginsDir) + }) + + conf.Server.Plugins.Folder = tempPluginsDir + m = createManager(nil, metrics.NewNoopInstance()) + }) + + // Helper to create a complete valid plugin for manager testing + createValidPlugin := func(folderName, manifestName string) { + pluginDir := filepath.Join(tempPluginsDir, folderName) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Copy real WASM file from testdata + sourceWasmPath := filepath.Join(testDataDir, "fake_artist_agent", "plugin.wasm") + targetWasmPath := filepath.Join(pluginDir, "plugin.wasm") + sourceWasm, err := os.ReadFile(sourceWasmPath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(targetWasmPath, sourceWasm, 0600)).To(Succeed()) + + manifest := `{ + "name": "` + manifestName + `", + "version": "1.0.0", + "capabilities": ["MetadataAgent"], + "author": "Test Author", + "description": "Test Plugin", + "website": "https://test.navidrome.org/` + manifestName + `", + "permissions": {} + }` + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed()) + } + + It("should register and compile discovered plugins", func() { + createValidPlugin("test-plugin", "test-plugin") + + m.ScanPlugins() + + // Focus on manager behavior: registration and compilation + Expect(m.plugins).To(HaveLen(1)) + Expect(m.plugins).To(HaveKey("test-plugin")) + + plugin := m.plugins["test-plugin"] + Expect(plugin.ID).To(Equal("test-plugin")) + Expect(plugin.Manifest.Name).To(Equal("test-plugin")) + + // Verify plugin can be loaded (compilation successful) + loadedPlugin := m.LoadPlugin("test-plugin", CapabilityMetadataAgent) + Expect(loadedPlugin).NotTo(BeNil()) + }) + + It("should handle multiple plugins with different IDs but same manifest names", func() { + // This tests manager-specific behavior: how it handles ID conflicts + createValidPlugin("lastfm-official", "lastfm") + createValidPlugin("lastfm-custom", "lastfm") + + m.ScanPlugins() + + // Both should be registered with their folder names as IDs + Expect(m.plugins).To(HaveLen(2)) + Expect(m.plugins).To(HaveKey("lastfm-official")) + Expect(m.plugins).To(HaveKey("lastfm-custom")) + + // Both should be loadable independently + official := m.LoadPlugin("lastfm-official", CapabilityMetadataAgent) + custom := m.LoadPlugin("lastfm-custom", CapabilityMetadataAgent) + Expect(official).NotTo(BeNil()) + Expect(custom).NotTo(BeNil()) + Expect(official.PluginID()).To(Equal("lastfm-official")) + Expect(custom.PluginID()).To(Equal("lastfm-custom")) + }) + }) + + Describe("LoadPlugin", func() { + It("should load a MetadataAgent plugin and invoke artist-related methods", func() { + plugin := mgr.LoadPlugin("fake_artist_agent", CapabilityMetadataAgent) + Expect(plugin).NotTo(BeNil()) + + agent, ok := plugin.(agents.Interface) + Expect(ok).To(BeTrue(), "plugin should implement agents.Interface") + Expect(agent.AgentName()).To(Equal("fake_artist_agent")) + + mbidRetriever, ok := agent.(agents.ArtistMBIDRetriever) + Expect(ok).To(BeTrue()) + mbid, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist") + Expect(err).NotTo(HaveOccurred()) + Expect(mbid).To(Equal("1234567890")) + }) + }) + + Describe("EnsureCompiled", func() { + It("should successfully wait for plugin compilation", func() { + err := mgr.EnsureCompiled("fake_artist_agent") + Expect(err).NotTo(HaveOccurred()) + }) + + It("should return error for non-existent plugin", func() { + err := mgr.EnsureCompiled("non-existent-plugin") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("plugin not found: non-existent-plugin")) + }) + + It("should wait for compilation to complete for all valid plugins", func() { + pluginNames := []string{"fake_artist_agent", "fake_album_agent", "multi_plugin", "fake_scrobbler"} + + for _, name := range pluginNames { + err := mgr.EnsureCompiled(name) + Expect(err).NotTo(HaveOccurred(), "plugin %s should compile successfully", name) + } + }) + }) + + Describe("Invoke Methods", func() { + It("should load all MetadataAgent plugins and invoke methods", func() { + fakeAlbumPlugin, isMediaAgent := mgr.LoadMediaAgent("fake_album_agent") + Expect(isMediaAgent).To(BeTrue()) + + Expect(fakeAlbumPlugin).NotTo(BeNil(), "fake_album_agent should be loaded") + + // Test GetAlbumInfo method - need to cast to the specific interface + albumRetriever, ok := fakeAlbumPlugin.(agents.AlbumInfoRetriever) + Expect(ok).To(BeTrue(), "fake_album_agent should implement AlbumInfoRetriever") + + info, err := albumRetriever.GetAlbumInfo(ctx, "Test Album", "Test Artist", "123") + Expect(err).NotTo(HaveOccurred()) + Expect(info).NotTo(BeNil()) + Expect(info.Name).To(Equal("Test Album")) + }) + }) + + Describe("Permission Enforcement Integration", func() { + It("should fail when plugin tries to access unauthorized services", func() { + // This plugin tries to access config service but has no permissions + plugin := mgr.LoadPlugin("unauthorized_plugin", CapabilityMetadataAgent) + Expect(plugin).NotTo(BeNil()) + + agent, ok := plugin.(agents.Interface) + Expect(ok).To(BeTrue()) + + // This should fail because the plugin tries to access unauthorized config service + // The exact behavior depends on the plugin implementation, but it should either: + // 1. Fail during instantiation, or + // 2. Return an error when trying to call config methods + + // Try to use one of the available methods - let's test with GetArtistMBID + mbidRetriever, isMBIDRetriever := agent.(agents.ArtistMBIDRetriever) + if isMBIDRetriever { + _, err := mbidRetriever.GetArtistMBID(ctx, "id", "Test Artist") + if err == nil { + // If no error, the plugin should still be working + // but any config access should fail silently or return default values + Expect(agent.AgentName()).To(Equal("unauthorized_plugin")) + } else { + // If there's an error, it should be related to missing permissions + Expect(err.Error()).To(ContainSubstring("")) + } + } else { + // If the plugin doesn't implement the interface, that's also acceptable + Expect(agent.AgentName()).To(Equal("unauthorized_plugin")) + } + }) + }) + + Describe("Plugin Initialization Lifecycle", func() { + BeforeEach(func() { + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = testDataDir + }) + + Context("when OnInit is successful", func() { + It("should register and initialize the plugin", func() { + conf.Server.PluginConfig = nil + mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config + mgr.ScanPlugins() + + plugin := mgr.plugins["fake_init_service"] + Expect(plugin).NotTo(BeNil()) + + Eventually(func() bool { + return mgr.lifecycle.isInitialized(plugin) + }).Should(BeTrue()) + + // Check that the plugin is still registered + names := mgr.PluginNames(CapabilityLifecycleManagement) + Expect(names).To(ContainElement("fake_init_service")) + }) + }) + + Context("when OnInit fails", func() { + It("should unregister the plugin if OnInit returns an error string", func() { + conf.Server.PluginConfig = map[string]map[string]string{ + "fake_init_service": { + "returnError": "response_error", + }, + } + mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config + mgr.ScanPlugins() + + Eventually(func() []string { + return mgr.PluginNames(CapabilityLifecycleManagement) + }).ShouldNot(ContainElement("fake_init_service")) + }) + + It("should unregister the plugin if OnInit returns a Go error", func() { + conf.Server.PluginConfig = map[string]map[string]string{ + "fake_init_service": { + "returnError": "go_error", + }, + } + mgr = createManager(nil, metrics.NewNoopInstance()) // Create manager after setting config + mgr.ScanPlugins() + + Eventually(func() []string { + return mgr.PluginNames(CapabilityLifecycleManagement) + }).ShouldNot(ContainElement("fake_init_service")) + }) + }) + + It("should clear lifecycle state when unregistering a plugin", func() { + // Create a manager and register a plugin + mgr := createManager(nil, metrics.NewNoopInstance()) + + // Create a mock plugin with LifecycleManagement capability + plugin := &plugin{ + ID: "test-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + // Register the plugin in the manager + mgr.pluginsMu.Lock() + mgr.plugins[plugin.ID] = plugin + mgr.pluginsMu.Unlock() + + // Mark the plugin as initialized in the lifecycle manager + mgr.lifecycle.markInitialized(plugin) + Expect(mgr.lifecycle.isInitialized(plugin)).To(BeTrue()) + + // Unregister the plugin + mgr.unregisterPlugin(plugin.ID) + + // Verify that the plugin is no longer in the manager + mgr.pluginsMu.RLock() + _, exists := mgr.plugins[plugin.ID] + mgr.pluginsMu.RUnlock() + Expect(exists).To(BeFalse()) + + // Verify that the lifecycle state has been cleared + Expect(mgr.lifecycle.isInitialized(plugin)).To(BeFalse()) + }) + }) +}) diff --git a/plugins/manifest.go b/plugins/manifest.go new file mode 100644 index 0000000..b56187b --- /dev/null +++ b/plugins/manifest.go @@ -0,0 +1,30 @@ +package plugins + +//go:generate go tool go-jsonschema --schema-root-type navidrome://plugins/manifest=PluginManifest -p schema --output schema/manifest_gen.go schema/manifest.schema.json + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// LoadManifest loads and parses the manifest.json file from the given plugin directory. +// Returns the generated schema.PluginManifest type with full validation and type safety. +func LoadManifest(pluginDir string) (*schema.PluginManifest, error) { + manifestPath := filepath.Join(pluginDir, "manifest.json") + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest file: %w", err) + } + + var manifest schema.PluginManifest + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("invalid manifest: %w", err) + } + + return &manifest, nil +} diff --git a/plugins/manifest_permissions_test.go b/plugins/manifest_permissions_test.go new file mode 100644 index 0000000..7a3df5f --- /dev/null +++ b/plugins/manifest_permissions_test.go @@ -0,0 +1,526 @@ +package plugins + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Helper function to create test plugins with typed permissions +func createTestPlugin(tempDir, name string, permissions schema.PluginManifestPermissions) string { + pluginDir := filepath.Join(tempDir, name) + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + // Use the generated PluginManifest type directly - it handles JSON marshaling automatically + manifest := schema.PluginManifest{ + Name: name, + Author: "Test Author", + Version: "1.0.0", + Description: "Test plugin for permissions", + Website: "https://test.navidrome.org/" + name, + Capabilities: []schema.PluginManifestCapabilitiesElem{ + schema.PluginManifestCapabilitiesElemMetadataAgent, + }, + Permissions: permissions, + } + + // Marshal the typed manifest directly - gets all validation for free + manifestData, err := json.Marshal(manifest) + Expect(err).NotTo(HaveOccurred()) + + manifestPath := filepath.Join(pluginDir, "manifest.json") + Expect(os.WriteFile(manifestPath, manifestData, 0600)).To(Succeed()) + + // Create fake WASM file (since plugin discovery checks for it) + wasmPath := filepath.Join(pluginDir, "plugin.wasm") + Expect(os.WriteFile(wasmPath, []byte("fake wasm content"), 0600)).To(Succeed()) + + return pluginDir +} + +var _ = Describe("Plugin Permissions", func() { + var ( + mgr *managerImpl + tempDir string + ctx context.Context + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ctx = context.Background() + mgr = createManager(nil, metrics.NewNoopInstance()) + tempDir = GinkgoT().TempDir() + }) + + Describe("Permission Enforcement in createRuntime", func() { + It("should only load services specified in permissions", func() { + // Test with limited permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + + runtimeFunc := mgr.createRuntime("test-plugin", permissions) + + // Create runtime to test service availability + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // The runtime was created successfully with the specified permissions + Expect(runtime).NotTo(BeNil()) + + // Note: The actual verification of which specific host functions are available + // would require introspecting the WASM runtime, which is complex. + // The key test is that the runtime creation succeeds with valid permissions. + }) + + It("should create runtime with empty permissions", func() { + permissions := schema.PluginManifestPermissions{} + + runtimeFunc := mgr.createRuntime("empty-permissions-plugin", permissions) + + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Should succeed but with no host services available + Expect(runtime).NotTo(BeNil()) + }) + + It("should handle all available permissions", func() { + // Test with all possible permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic tasks", + }, + Websocket: &schema.PluginManifestPermissionsWebsocket{ + Reason: "To handle real-time communication", + AllowedUrls: []string{"wss://api.example.com"}, + AllowLocalNetwork: false, + }, + Cache: &schema.PluginManifestPermissionsCache{ + Reason: "To cache data and reduce API calls", + }, + Artwork: &schema.PluginManifestPermissionsArtwork{ + Reason: "To generate artwork URLs", + }, + } + + runtimeFunc := mgr.createRuntime("full-permissions-plugin", permissions) + + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + Expect(runtime).NotTo(BeNil()) + }) + }) + + Describe("Plugin Discovery with Permissions", func() { + BeforeEach(func() { + conf.Server.Plugins.Folder = tempDir + }) + + It("should discover plugin with valid permissions manifest", func() { + // Create plugin with http permission using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch metadata from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + }, + } + createTestPlugin(tempDir, "valid-plugin", permissions) + + // Scan for plugins + mgr.ScanPlugins() + + // Verify plugin was discovered (even without valid WASM) + pluginNames := mgr.PluginNames("MetadataAgent") + Expect(pluginNames).To(ContainElement("valid-plugin")) + }) + + It("should discover plugin with no permissions", func() { + // Create plugin with empty permissions using typed structs + permissions := schema.PluginManifestPermissions{} + createTestPlugin(tempDir, "no-perms-plugin", permissions) + + mgr.ScanPlugins() + + pluginNames := mgr.PluginNames("MetadataAgent") + Expect(pluginNames).To(ContainElement("no-perms-plugin")) + }) + + It("should discover plugin with multiple permissions", func() { + // Create plugin with multiple permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch metadata from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read plugin configuration settings", + }, + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic data updates", + }, + } + createTestPlugin(tempDir, "multi-perms-plugin", permissions) + + mgr.ScanPlugins() + + pluginNames := mgr.PluginNames("MetadataAgent") + Expect(pluginNames).To(ContainElement("multi-perms-plugin")) + }) + }) + + Describe("Existing Plugin Permissions", func() { + BeforeEach(func() { + // Use the testdata directory with updated plugins + conf.Server.Plugins.Folder = testDataDir + mgr.ScanPlugins() + }) + + It("should discover fake_scrobbler with empty permissions", func() { + scrobblerNames := mgr.PluginNames(CapabilityScrobbler) + Expect(scrobblerNames).To(ContainElement("fake_scrobbler")) + }) + + It("should discover multi_plugin with scheduler permissions", func() { + agentNames := mgr.PluginNames(CapabilityMetadataAgent) + Expect(agentNames).To(ContainElement("multi_plugin")) + }) + + It("should discover all test plugins successfully", func() { + // All test plugins should be discovered with their updated permissions + testPlugins := []struct { + name string + capability string + }{ + {"fake_album_agent", CapabilityMetadataAgent}, + {"fake_artist_agent", CapabilityMetadataAgent}, + {"fake_scrobbler", CapabilityScrobbler}, + {"multi_plugin", CapabilityMetadataAgent}, + {"fake_init_service", CapabilityLifecycleManagement}, + } + + for _, testPlugin := range testPlugins { + pluginNames := mgr.PluginNames(testPlugin.capability) + Expect(pluginNames).To(ContainElement(testPlugin.name), "Plugin %s should be discovered", testPlugin.name) + } + }) + }) + + Describe("Permission Validation", func() { + It("should enforce permissions are required in manifest", func() { + // Create a manifest JSON string without the permissions field + manifestContent := `{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent"] + }` + + manifestPath := filepath.Join(tempDir, "manifest.json") + err := os.WriteFile(manifestPath, []byte(manifestContent), 0600) + Expect(err).NotTo(HaveOccurred()) + + _, err = LoadManifest(tempDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field permissions in PluginManifest: required")) + }) + + It("should allow unknown permission keys", func() { + // Create manifest with both known and unknown permission types + pluginDir := filepath.Join(tempDir, "unknown-perms") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + manifestContent := `{ + "name": "unknown-perms", + "author": "Test Author", + "version": "1.0.0", + "description": "Manifest with unknown permissions", + "website": "https://test.navidrome.org/unknown-perms", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch data from external APIs", + "allowedUrls": { + "*": ["*"] + } + }, + "unknown": { + "customField": "customValue" + } + } + }` + + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + // Test manifest loading directly - should succeed even with unknown permissions + loadedManifest, err := LoadManifest(pluginDir) + Expect(err).NotTo(HaveOccurred()) + Expect(loadedManifest).NotTo(BeNil()) + // With typed permissions, we check the specific fields + Expect(loadedManifest.Permissions.Http).NotTo(BeNil()) + Expect(loadedManifest.Permissions.Http.Reason).To(Equal("To fetch data from external APIs")) + // The key point is that the manifest loads successfully despite unknown permissions + // The actual handling of AdditionalProperties depends on the JSON schema implementation + }) + }) + + Describe("Runtime Pool with Permissions", func() { + It("should create separate runtimes for different permission sets", func() { + // Create two different permission sets using typed structs + permissions1 := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + } + permissions2 := schema.PluginManifestPermissions{ + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + + runtimeFunc1 := mgr.createRuntime("plugin1", permissions1) + runtimeFunc2 := mgr.createRuntime("plugin2", permissions2) + + runtime1, err1 := runtimeFunc1(ctx) + Expect(err1).NotTo(HaveOccurred()) + defer runtime1.Close(ctx) + + runtime2, err2 := runtimeFunc2(ctx) + Expect(err2).NotTo(HaveOccurred()) + defer runtime2.Close(ctx) + + // Should be different runtime instances + Expect(runtime1).NotTo(BeIdenticalTo(runtime2)) + }) + }) + + Describe("Permission System Integration", func() { + It("should successfully validate manifests with permissions", func() { + // Create a valid manifest with permissions + pluginDir := filepath.Join(tempDir, "valid-manifest") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + + manifestContent := `{ + "name": "valid-manifest", + "author": "Test Author", + "version": "1.0.0", + "description": "Valid manifest with permissions", + "website": "https://test.navidrome.org/valid-manifest", + "capabilities": ["MetadataAgent"], + "permissions": { + "http": { + "reason": "To fetch metadata from external APIs", + "allowedUrls": { + "*": ["*"] + } + }, + "config": { + "reason": "To read plugin configuration settings" + } + } + }` + + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + // Load the manifest - should succeed + manifest, err := LoadManifest(pluginDir) + Expect(err).NotTo(HaveOccurred()) + Expect(manifest).NotTo(BeNil()) + // With typed permissions, check the specific permission fields + Expect(manifest.Permissions.Http).NotTo(BeNil()) + Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata from external APIs")) + Expect(manifest.Permissions.Config).NotTo(BeNil()) + Expect(manifest.Permissions.Config.Reason).To(Equal("To read plugin configuration settings")) + }) + + It("should track which services are requested per plugin", func() { + // Test that different plugins can have different permission sets + permissions1 := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + permissions2 := schema.PluginManifestPermissions{ + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic tasks", + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration for scheduler", + }, + } + permissions3 := schema.PluginManifestPermissions{} // Empty permissions + + createTestPlugin(tempDir, "plugin-with-http", permissions1) + createTestPlugin(tempDir, "plugin-with-scheduler", permissions2) + createTestPlugin(tempDir, "plugin-with-none", permissions3) + + conf.Server.Plugins.Folder = tempDir + mgr.ScanPlugins() + + // All should be discovered + pluginNames := mgr.PluginNames(CapabilityMetadataAgent) + Expect(pluginNames).To(ContainElement("plugin-with-http")) + Expect(pluginNames).To(ContainElement("plugin-with-scheduler")) + Expect(pluginNames).To(ContainElement("plugin-with-none")) + }) + }) + + Describe("Runtime Service Access Control", func() { + It("should successfully create runtime with permitted services", func() { + // Create runtime with HTTP permission using typed struct + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + } + + runtimeFunc := mgr.createRuntime("http-only-plugin", permissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should be created successfully - host functions are loaded during runtime creation + Expect(runtime).NotTo(BeNil()) + }) + + It("should successfully create runtime with multiple permitted services", func() { + // Create runtime with multiple permissions using typed structs + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + Scheduler: &schema.PluginManifestPermissionsScheduler{ + Reason: "To schedule periodic tasks", + }, + } + + runtimeFunc := mgr.createRuntime("multi-service-plugin", permissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should be created successfully + Expect(runtime).NotTo(BeNil()) + }) + + It("should create runtime with no services when no permissions granted", func() { + // Create runtime with empty permissions using typed struct + emptyPermissions := schema.PluginManifestPermissions{} + + runtimeFunc := mgr.createRuntime("no-service-plugin", emptyPermissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should still be created, but with no host services + Expect(runtime).NotTo(BeNil()) + }) + + It("should demonstrate secure-by-default behavior", func() { + // Test that default (empty permissions) provides no services + defaultPermissions := schema.PluginManifestPermissions{} + runtimeFunc := mgr.createRuntime("default-plugin", defaultPermissions) + runtime, err := runtimeFunc(ctx) + Expect(err).NotTo(HaveOccurred()) + defer runtime.Close(ctx) + + // Runtime should be created but with no host services + Expect(runtime).NotTo(BeNil()) + }) + + It("should test permission enforcement by simulating unauthorized service access", func() { + // This test demonstrates that plugins would fail at runtime when trying to call + // host functions they don't have permission for, since those functions are simply + // not loaded into the WASM runtime environment. + + // Create two different runtimes with different permissions using typed structs + httpOnlyPermissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "To fetch data from external APIs", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + } + configOnlyPermissions := schema.PluginManifestPermissions{ + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "To read configuration settings", + }, + } + + httpRuntime, err := mgr.createRuntime("http-only", httpOnlyPermissions)(ctx) + Expect(err).NotTo(HaveOccurred()) + defer httpRuntime.Close(ctx) + + configRuntime, err := mgr.createRuntime("config-only", configOnlyPermissions)(ctx) + Expect(err).NotTo(HaveOccurred()) + defer configRuntime.Close(ctx) + + // Both runtimes should be created successfully, but they will have different + // sets of host functions available. A plugin trying to call unauthorized + // functions would get "function not found" errors during instantiation or execution. + Expect(httpRuntime).NotTo(BeNil()) + Expect(configRuntime).NotTo(BeNil()) + }) + }) +}) diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go new file mode 100644 index 0000000..2ec3edd --- /dev/null +++ b/plugins/manifest_test.go @@ -0,0 +1,144 @@ +package plugins + +import ( + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Plugin Manifest", func() { + var tempDir string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + }) + + It("should load and parse a valid manifest", func() { + manifestPath := filepath.Join(tempDir, "manifest.json") + manifestContent := []byte(`{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent", "Scrobbler"], + "permissions": { + "http": { + "reason": "To fetch metadata", + "allowedUrls": { + "https://api.example.com/*": ["GET"] + } + } + } + }`) + + err := os.WriteFile(manifestPath, manifestContent, 0600) + Expect(err).NotTo(HaveOccurred()) + + manifest, err := LoadManifest(tempDir) + Expect(err).NotTo(HaveOccurred()) + Expect(manifest).NotTo(BeNil()) + Expect(manifest.Name).To(Equal("test-plugin")) + Expect(manifest.Author).To(Equal("Test Author")) + Expect(manifest.Version).To(Equal("1.0.0")) + Expect(manifest.Description).To(Equal("A test plugin")) + Expect(manifest.Capabilities).To(HaveLen(2)) + Expect(manifest.Capabilities[0]).To(Equal(schema.PluginManifestCapabilitiesElemMetadataAgent)) + Expect(manifest.Capabilities[1]).To(Equal(schema.PluginManifestCapabilitiesElemScrobbler)) + Expect(manifest.Permissions.Http).NotTo(BeNil()) + Expect(manifest.Permissions.Http.Reason).To(Equal("To fetch metadata")) + }) + + It("should fail with proper error for non-existent manifest", func() { + _, err := LoadManifest(filepath.Join(tempDir, "non-existent")) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read manifest file")) + }) + + It("should fail with JSON parse error for invalid JSON", func() { + // Create invalid JSON + invalidJSON := `{ + "name": "test-plugin", + "author": "Test Author" + "version": "1.0.0" + "description": "A test plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "invalid-json") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(invalidJSON), 0600)).To(Succeed()) + + // Test validation fails + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid manifest")) + }) + + It("should validate manifest against schema with detailed error for missing required field", func() { + // Create manifest missing required name field + manifestContent := `{ + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "test-plugin") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field name in PluginManifest: required")) + }) + + It("should validate manifest with wrong capability type", func() { + // Create manifest with invalid capability + manifestContent := `{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["UnsupportedService"], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "test-plugin") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("invalid value")) + Expect(err.Error()).To(ContainSubstring("UnsupportedService")) + }) + + It("should validate manifest with empty capabilities array", func() { + // Create manifest with empty capabilities array + manifestContent := `{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": [], + "permissions": {} + }` + + pluginDir := filepath.Join(tempDir, "test-plugin") + Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifestContent), 0600)).To(Succeed()) + + _, err := LoadManifest(pluginDir) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("field capabilities length: must be >= 1")) + }) +}) diff --git a/plugins/package.go b/plugins/package.go new file mode 100644 index 0000000..5273b04 --- /dev/null +++ b/plugins/package.go @@ -0,0 +1,177 @@ +package plugins + +import ( + "archive/zip" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/plugins/schema" +) + +// PluginPackage represents a Navidrome Plugin Package (.ndp file) +type PluginPackage struct { + ManifestJSON []byte + Manifest *schema.PluginManifest + WasmBytes []byte + Docs map[string][]byte +} + +// ExtractPackage extracts a .ndp file to the target directory +func ExtractPackage(ndpPath, targetDir string) error { + r, err := zip.OpenReader(ndpPath) + if err != nil { + return fmt.Errorf("error opening .ndp file: %w", err) + } + defer r.Close() + + // Create target directory if it doesn't exist + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("error creating plugin directory: %w", err) + } + + // Define a reasonable size limit for plugin files to prevent decompression bombs + const maxFileSize = 10 * 1024 * 1024 // 10 MB limit + + // Extract all files from the zip + for _, f := range r.File { + // Skip directories (they will be created as needed) + if f.FileInfo().IsDir() { + continue + } + + // Create the file path for extraction + // Validate the file name to prevent directory traversal or absolute paths + if strings.Contains(f.Name, "..") || filepath.IsAbs(f.Name) { + return fmt.Errorf("illegal file path in plugin package: %s", f.Name) + } + + // Create the file path for extraction + targetPath := filepath.Join(targetDir, f.Name) // #nosec G305 + + // Clean the path to prevent directory traversal. + cleanedPath := filepath.Clean(targetPath) + // Ensure the cleaned path is still within the target directory. + // We resolve both paths to absolute paths to be sure. + absTargetDir, err := filepath.Abs(targetDir) + if err != nil { + return fmt.Errorf("failed to resolve target directory path: %w", err) + } + absTargetPath, err := filepath.Abs(cleanedPath) + if err != nil { + return fmt.Errorf("failed to resolve extracted file path: %w", err) + } + if !strings.HasPrefix(absTargetPath, absTargetDir+string(os.PathSeparator)) && absTargetPath != absTargetDir { + return fmt.Errorf("illegal file path in plugin package: %s", f.Name) + } + + // Open the file inside the zip + rc, err := f.Open() + if err != nil { + return fmt.Errorf("error opening file in plugin package: %w", err) + } + + // Create parent directories if they don't exist + if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil { + rc.Close() + return fmt.Errorf("error creating directory structure: %w", err) + } + + // Create the file + outFile, err := os.Create(targetPath) + if err != nil { + rc.Close() + return fmt.Errorf("error creating extracted file: %w", err) + } + + // Copy the file contents with size limit + if _, err := io.CopyN(outFile, rc, maxFileSize); err != nil && !errors.Is(err, io.EOF) { + outFile.Close() + rc.Close() + if errors.Is(err, io.ErrUnexpectedEOF) { // File size exceeds limit + return fmt.Errorf("error extracting file: size exceeds limit (%d bytes) for %s", maxFileSize, f.Name) + } + return fmt.Errorf("error writing extracted file: %w", err) + } + + outFile.Close() + rc.Close() + + // Set appropriate file permissions (0600 - readable only by owner) + if err := os.Chmod(targetPath, 0600); err != nil { + return fmt.Errorf("error setting permissions on extracted file: %w", err) + } + } + + return nil +} + +// LoadPackage loads and validates an .ndp file without extracting it +func LoadPackage(ndpPath string) (*PluginPackage, error) { + r, err := zip.OpenReader(ndpPath) + if err != nil { + return nil, fmt.Errorf("error opening .ndp file: %w", err) + } + defer r.Close() + + pkg := &PluginPackage{ + Docs: make(map[string][]byte), + } + + // Required files + var hasManifest, hasWasm bool + + // Read all files in the zip + for _, f := range r.File { + // Skip directories + if f.FileInfo().IsDir() { + continue + } + + // Get file content + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("error opening file in plugin package: %w", err) + } + + content, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("error reading file in plugin package: %w", err) + } + + // Process based on file name + switch strings.ToLower(f.Name) { + case "manifest.json": + pkg.ManifestJSON = content + hasManifest = true + case "plugin.wasm": + pkg.WasmBytes = content + hasWasm = true + default: + // Store other files as documentation + pkg.Docs[f.Name] = content + } + } + + // Ensure required files exist + if !hasManifest { + return nil, fmt.Errorf("plugin package missing required manifest.json") + } + if !hasWasm { + return nil, fmt.Errorf("plugin package missing required plugin.wasm") + } + + // Parse and validate the manifest + var manifest schema.PluginManifest + if err := json.Unmarshal(pkg.ManifestJSON, &manifest); err != nil { + return nil, fmt.Errorf("invalid manifest: %w", err) + } + + pkg.Manifest = &manifest + return pkg, nil +} diff --git a/plugins/package_test.go b/plugins/package_test.go new file mode 100644 index 0000000..8ff4b35 --- /dev/null +++ b/plugins/package_test.go @@ -0,0 +1,116 @@ +package plugins + +import ( + "archive/zip" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Plugin Package", func() { + var tempDir string + var ndpPath string + + BeforeEach(func() { + tempDir = GinkgoT().TempDir() + + // Create a test .ndp file + ndpPath = filepath.Join(tempDir, "test-plugin.ndp") + + // Create the required plugin files + manifestContent := []byte(`{ + "name": "test-plugin", + "author": "Test Author", + "version": "1.0.0", + "description": "A test plugin", + "website": "https://test.navidrome.org/test-plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} + }`) + + wasmContent := []byte("dummy wasm content") + readmeContent := []byte("# Test Plugin\nThis is a test plugin") + + // Create the zip file + zipFile, err := os.Create(ndpPath) + Expect(err).NotTo(HaveOccurred()) + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Add manifest.json + manifestWriter, err := zipWriter.Create("manifest.json") + Expect(err).NotTo(HaveOccurred()) + _, err = manifestWriter.Write(manifestContent) + Expect(err).NotTo(HaveOccurred()) + + // Add plugin.wasm + wasmWriter, err := zipWriter.Create("plugin.wasm") + Expect(err).NotTo(HaveOccurred()) + _, err = wasmWriter.Write(wasmContent) + Expect(err).NotTo(HaveOccurred()) + + // Add README.md + readmeWriter, err := zipWriter.Create("README.md") + Expect(err).NotTo(HaveOccurred()) + _, err = readmeWriter.Write(readmeContent) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should load and validate a plugin package", func() { + pkg, err := LoadPackage(ndpPath) + Expect(err).NotTo(HaveOccurred()) + Expect(pkg).NotTo(BeNil()) + + // Check manifest was parsed + Expect(pkg.Manifest).NotTo(BeNil()) + 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.Manifest.Description).To(Equal("A test plugin")) + Expect(pkg.Manifest.Capabilities).To(HaveLen(1)) + Expect(pkg.Manifest.Capabilities[0]).To(Equal(schema.PluginManifestCapabilitiesElemMetadataAgent)) + + // Check WASM file was loaded + Expect(pkg.WasmBytes).NotTo(BeEmpty()) + + // Check docs were loaded + Expect(pkg.Docs).To(HaveKey("README.md")) + }) + + It("should extract a plugin package to a directory", func() { + targetDir := filepath.Join(tempDir, "extracted") + + err := ExtractPackage(ndpPath, targetDir) + Expect(err).NotTo(HaveOccurred()) + + // Check files were extracted + Expect(filepath.Join(targetDir, "manifest.json")).To(BeARegularFile()) + Expect(filepath.Join(targetDir, "plugin.wasm")).To(BeARegularFile()) + Expect(filepath.Join(targetDir, "README.md")).To(BeARegularFile()) + }) + + It("should fail to load an invalid package", func() { + // Create an invalid package (missing required files) + invalidPath := filepath.Join(tempDir, "invalid.ndp") + zipFile, err := os.Create(invalidPath) + Expect(err).NotTo(HaveOccurred()) + + zipWriter := zip.NewWriter(zipFile) + // Only add a README, missing manifest and wasm + readmeWriter, err := zipWriter.Create("README.md") + Expect(err).NotTo(HaveOccurred()) + _, err = readmeWriter.Write([]byte("Invalid package")) + Expect(err).NotTo(HaveOccurred()) + zipWriter.Close() + zipFile.Close() + + // Test loading fails + _, err = LoadPackage(invalidPath) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/plugins/plugin_lifecycle_manager.go b/plugins/plugin_lifecycle_manager.go new file mode 100644 index 0000000..e00e7e5 --- /dev/null +++ b/plugins/plugin_lifecycle_manager.go @@ -0,0 +1,95 @@ +package plugins + +import ( + "context" + "maps" + "sync" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" +) + +// pluginLifecycleManager tracks which plugins have been initialized and manages their lifecycle +type pluginLifecycleManager struct { + plugins sync.Map // string -> bool + config map[string]map[string]string + metrics metrics.Metrics +} + +// newPluginLifecycleManager creates a new plugin lifecycle manager +func newPluginLifecycleManager(metrics metrics.Metrics) *pluginLifecycleManager { + config := maps.Clone(conf.Server.PluginConfig) + return &pluginLifecycleManager{ + config: config, + metrics: metrics, + } +} + +// isInitialized checks if a plugin has been initialized +func (m *pluginLifecycleManager) isInitialized(plugin *plugin) bool { + key := plugin.ID + consts.Zwsp + plugin.Manifest.Version + value, exists := m.plugins.Load(key) + return exists && value.(bool) +} + +// markInitialized marks a plugin as initialized +func (m *pluginLifecycleManager) markInitialized(plugin *plugin) { + key := plugin.ID + consts.Zwsp + plugin.Manifest.Version + m.plugins.Store(key, true) +} + +// clearInitialized removes the initialization state of a plugin +func (m *pluginLifecycleManager) clearInitialized(plugin *plugin) { + key := plugin.ID + consts.Zwsp + plugin.Manifest.Version + m.plugins.Delete(key) +} + +// callOnInit calls the OnInit method on a plugin that implements LifecycleManagement +func (m *pluginLifecycleManager) callOnInit(plugin *plugin) error { + ctx := context.Background() + log.Debug("Initializing plugin", "name", plugin.ID) + start := time.Now() + + // Create LifecycleManagement plugin instance + loader, err := api.NewLifecycleManagementPlugin(ctx, api.WazeroRuntime(plugin.Runtime), api.WazeroModuleConfig(plugin.ModConfig)) + if loader == nil || err != nil { + log.Error("Error creating LifecycleManagement plugin", "plugin", plugin.ID, err) + return err + } + + initPlugin, err := loader.Load(ctx, plugin.WasmPath) + if err != nil { + log.Error("Error loading LifecycleManagement plugin", "plugin", plugin.ID, "path", plugin.WasmPath, err) + return err + } + defer initPlugin.Close(ctx) + + // Prepare the request with plugin-specific configuration + req := &api.InitRequest{} + + // Add plugin configuration if available + if m.config != nil { + if pluginConfig, ok := m.config[plugin.ID]; ok && len(pluginConfig) > 0 { + req.Config = maps.Clone(pluginConfig) + log.Debug("Passing configuration to plugin", "plugin", plugin.ID, "configKeys", len(pluginConfig)) + } + } + + // Call OnInit + callStart := time.Now() + _, err = checkErr(initPlugin.OnInit(ctx, req)) + m.metrics.RecordPluginRequest(ctx, plugin.ID, "OnInit", err == nil, time.Since(callStart).Milliseconds()) + if err != nil { + log.Error("Error initializing plugin", "plugin", plugin.ID, "elapsed", time.Since(start), err) + return err + } + + // Mark the plugin as initialized + m.markInitialized(plugin) + log.Debug("Plugin initialized successfully", "plugin", plugin.ID, "elapsed", time.Since(start)) + return nil +} diff --git a/plugins/plugin_lifecycle_manager_test.go b/plugins/plugin_lifecycle_manager_test.go new file mode 100644 index 0000000..800630c --- /dev/null +++ b/plugins/plugin_lifecycle_manager_test.go @@ -0,0 +1,166 @@ +package plugins + +import ( + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Helper function to check if a plugin implements LifecycleManagement +func hasInitService(info *plugin) bool { + for _, c := range info.Capabilities { + if c == CapabilityLifecycleManagement { + return true + } + } + return false +} + +var _ = Describe("LifecycleManagement", func() { + Describe("Plugin Lifecycle Manager", func() { + var lifecycleManager *pluginLifecycleManager + + BeforeEach(func() { + lifecycleManager = newPluginLifecycleManager(metrics.NewNoopInstance()) + }) + + It("should track initialization state of plugins", func() { + // Create test plugins + plugin1 := &plugin{ + ID: "test-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + plugin2 := &plugin{ + ID: "another-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "0.5.0", + }, + } + + // Initially, no plugins should be initialized + Expect(lifecycleManager.isInitialized(plugin1)).To(BeFalse()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse()) + + // Mark first plugin as initialized + lifecycleManager.markInitialized(plugin1) + + // Check state + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse()) + + // Mark second plugin as initialized + lifecycleManager.markInitialized(plugin2) + + // Both should be initialized now + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeTrue()) + }) + + It("should handle plugins with same name but different versions", func() { + plugin1 := &plugin{ + ID: "test-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + plugin2 := &plugin{ + ID: "test-plugin", // Same name + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "2.0.0", // Different version + }, + } + + // Mark v1 as initialized + lifecycleManager.markInitialized(plugin1) + + // v1 should be initialized but not v2 + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeFalse()) + + // Mark v2 as initialized + lifecycleManager.markInitialized(plugin2) + + // Both versions should be initialized now + Expect(lifecycleManager.isInitialized(plugin1)).To(BeTrue()) + Expect(lifecycleManager.isInitialized(plugin2)).To(BeTrue()) + + // Verify the keys used for tracking + key1 := plugin1.ID + consts.Zwsp + plugin1.Manifest.Version + key2 := plugin1.ID + consts.Zwsp + plugin2.Manifest.Version + _, exists1 := lifecycleManager.plugins.Load(key1) + _, exists2 := lifecycleManager.plugins.Load(key2) + Expect(exists1).To(BeTrue()) + Expect(exists2).To(BeTrue()) + Expect(key1).NotTo(Equal(key2)) + }) + + It("should only consider plugins that implement LifecycleManagement", func() { + // Plugin that implements LifecycleManagement + initPlugin := &plugin{ + ID: "init-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + // Plugin that doesn't implement LifecycleManagement + regularPlugin := &plugin{ + ID: "regular-plugin", + Capabilities: []string{"MetadataAgent"}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + // Check if plugins can be initialized + Expect(hasInitService(initPlugin)).To(BeTrue()) + Expect(hasInitService(regularPlugin)).To(BeFalse()) + }) + + It("should properly construct the plugin key", func() { + plugin := &plugin{ + ID: "test-plugin", + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + expectedKey := "test-plugin" + consts.Zwsp + "1.0.0" + actualKey := plugin.ID + consts.Zwsp + plugin.Manifest.Version + + Expect(actualKey).To(Equal(expectedKey)) + }) + + It("should clear initialization state when requested", func() { + plugin := &plugin{ + ID: "test-plugin", + Capabilities: []string{CapabilityLifecycleManagement}, + Manifest: &schema.PluginManifest{ + Version: "1.0.0", + }, + } + + // Initially not initialized + Expect(lifecycleManager.isInitialized(plugin)).To(BeFalse()) + + // Mark as initialized + lifecycleManager.markInitialized(plugin) + Expect(lifecycleManager.isInitialized(plugin)).To(BeTrue()) + + // Clear initialization state + lifecycleManager.clearInitialized(plugin) + Expect(lifecycleManager.isInitialized(plugin)).To(BeFalse()) + }) + }) +}) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go new file mode 100644 index 0000000..1534263 --- /dev/null +++ b/plugins/plugins_suite_test.go @@ -0,0 +1,32 @@ +package plugins + +import ( + "os/exec" + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const testDataDir = "plugins/testdata" + +func TestPlugins(t *testing.T) { + tests.Init(t, false) + buildTestPlugins(t, testDataDir) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Plugins Suite") +} + +func buildTestPlugins(t *testing.T, path string) { + t.Helper() + t.Logf("[BeforeSuite] Current working directory: %s", path) + cmd := exec.Command("make", "-C", path) + out, err := cmd.CombinedOutput() + t.Logf("[BeforeSuite] Make output: %s", string(out)) + if err != nil { + t.Fatalf("Failed to build test plugins: %v", err) + } +} diff --git a/plugins/runtime.go b/plugins/runtime.go new file mode 100644 index 0000000..ee298e6 --- /dev/null +++ b/plugins/runtime.go @@ -0,0 +1,626 @@ +package plugins + +import ( + "context" + "crypto/md5" + "fmt" + "io/fs" + "maps" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/dustin/go-humanize" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/artwork" + "github.com/navidrome/navidrome/plugins/host/cache" + "github.com/navidrome/navidrome/plugins/host/config" + "github.com/navidrome/navidrome/plugins/host/http" + "github.com/navidrome/navidrome/plugins/host/scheduler" + "github.com/navidrome/navidrome/plugins/host/subsonicapi" + "github.com/navidrome/navidrome/plugins/host/websocket" + "github.com/navidrome/navidrome/plugins/schema" + "github.com/tetratelabs/wazero" + wazeroapi "github.com/tetratelabs/wazero/api" + "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" +) + +const maxParallelCompilations = 2 // Limit to 2 concurrent compilations + +var ( + compileSemaphore = make(chan struct{}, maxParallelCompilations) + compilationCache wazero.CompilationCache + cacheOnce sync.Once + runtimePool sync.Map // map[string]*cachingRuntime +) + +// createRuntime returns a function that creates a new wazero runtime and instantiates the required host functions +// based on the given plugin permissions +func (m *managerImpl) createRuntime(pluginID string, permissions schema.PluginManifestPermissions) api.WazeroNewRuntime { + return func(ctx context.Context) (wazero.Runtime, error) { + // Check if runtime already exists + if rt, ok := runtimePool.Load(pluginID); ok { + log.Trace(ctx, "Using existing runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", rt)) + // Return a new wrapper for each call, so each instance gets its own module capture + return newScopedRuntime(rt.(wazero.Runtime)), nil + } + + // Create new runtime with all the setup + cachingRT, err := m.createCachingRuntime(ctx, pluginID, permissions) + if err != nil { + return nil, err + } + + // Use LoadOrStore to atomically check and store, preventing race conditions + if existing, loaded := runtimePool.LoadOrStore(pluginID, cachingRT); loaded { + // Another goroutine created the runtime first, close ours and return the existing one + log.Trace(ctx, "Race condition detected, using existing runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", existing)) + _ = cachingRT.Close(ctx) + return newScopedRuntime(existing.(wazero.Runtime)), nil + } + + log.Trace(ctx, "Created new runtime", "plugin", pluginID, "runtime", fmt.Sprintf("%p", cachingRT)) + return newScopedRuntime(cachingRT), nil + } +} + +// createCachingRuntime handles the complex logic of setting up a new cachingRuntime +func (m *managerImpl) createCachingRuntime(ctx context.Context, pluginID string, permissions schema.PluginManifestPermissions) (*cachingRuntime, error) { + // Get compilation cache + compCache, err := getCompilationCache() + if err != nil { + return nil, fmt.Errorf("failed to get compilation cache: %w", err) + } + + // Create the runtime + runtimeConfig := wazero.NewRuntimeConfig().WithCompilationCache(compCache) + r := wazero.NewRuntimeWithConfig(ctx, runtimeConfig) + if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { + return nil, err + } + + // Setup host services + if err := m.setupHostServices(ctx, r, pluginID, permissions); err != nil { + _ = r.Close(ctx) + return nil, err + } + + return newCachingRuntime(r, pluginID), nil +} + +// setupHostServices configures all the permitted host services for a plugin +func (m *managerImpl) setupHostServices(ctx context.Context, r wazero.Runtime, pluginID string, permissions schema.PluginManifestPermissions) error { + // Define all available host services + type hostService struct { + name string + isPermitted bool + loadFunc func() (map[string]wazeroapi.FunctionDefinition, error) + } + + // List of all available host services with their permissions and loading functions + availableServices := []hostService{ + {"config", permissions.Config != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[config.ConfigService](ctx, config.Instantiate, &configServiceImpl{pluginID: pluginID}) + }}, + {"scheduler", permissions.Scheduler != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[scheduler.SchedulerService](ctx, scheduler.Instantiate, m.schedulerService.HostFunctions(pluginID)) + }}, + {"cache", permissions.Cache != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[cache.CacheService](ctx, cache.Instantiate, newCacheService(pluginID)) + }}, + {"artwork", permissions.Artwork != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + return loadHostLibrary[artwork.ArtworkService](ctx, artwork.Instantiate, &artworkServiceImpl{}) + }}, + {"http", permissions.Http != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + httpPerms, err := parseHTTPPermissions(permissions.Http) + if err != nil { + return nil, fmt.Errorf("invalid http permissions for plugin %s: %w", pluginID, err) + } + return loadHostLibrary[http.HttpService](ctx, http.Instantiate, &httpServiceImpl{ + pluginID: pluginID, + permissions: httpPerms, + }) + }}, + {"websocket", permissions.Websocket != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + wsPerms, err := parseWebSocketPermissions(permissions.Websocket) + if err != nil { + return nil, fmt.Errorf("invalid websocket permissions for plugin %s: %w", pluginID, err) + } + return loadHostLibrary[websocket.WebSocketService](ctx, websocket.Instantiate, m.websocketService.HostFunctions(pluginID, wsPerms)) + }}, + {"subsonicapi", permissions.Subsonicapi != nil, func() (map[string]wazeroapi.FunctionDefinition, error) { + if router := m.subsonicRouter.Load(); router != nil { + service := newSubsonicAPIService(pluginID, m.subsonicRouter.Load(), m.ds, permissions.Subsonicapi) + return loadHostLibrary[subsonicapi.SubsonicAPIService](ctx, subsonicapi.Instantiate, service) + } + log.Error(ctx, "SubsonicAPI service requested but router not available", "plugin", pluginID) + return nil, fmt.Errorf("SubsonicAPI router not available for plugin %s", pluginID) + }}, + } + + // Load only permitted services + var grantedPermissions []string + var libraries []map[string]wazeroapi.FunctionDefinition + for _, service := range availableServices { + if service.isPermitted { + lib, err := service.loadFunc() + if err != nil { + return fmt.Errorf("error loading %s lib: %w", service.name, err) + } + libraries = append(libraries, lib) + grantedPermissions = append(grantedPermissions, service.name) + } + } + log.Trace(ctx, "Granting permissions for plugin", "plugin", pluginID, "permissions", grantedPermissions) + + // Combine the permitted libraries + return combineLibraries(ctx, r, libraries...) +} + +// 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(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("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("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("Failed to traverse plugin cache directory", "path", dir, err) + } + return + } + + log.Trace("Current plugin cache size", "path", dir, "size", humanize.Bytes(total), "sizeLimit", humanize.Bytes(sizeLimit)) + if total <= sizeLimit { + return + } + + log.Debug("Purging plugin cache", "path", dir, "sizeLimit", humanize.Bytes(sizeLimit), "currentSize", humanize.Bytes(total)) + sort.Slice(files, func(i, j int) bool { return files[i].mod < files[j].mod }) + for _, f := range files { + if total <= sizeLimit { + break + } + if err := os.Remove(f.path); err != nil { + log.Warn("Failed to remove plugin cache entry", "path", f.path, "size", humanize.Bytes(f.size), err) + continue + } + total -= f.size + log.Debug("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) + } + } +} + +// getCompilationCache returns the global compilation cache, creating it if necessary +func getCompilationCache() (wazero.CompilationCache, error) { + var err error + cacheOnce.Do(func() { + cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins") + purgeCacheBySize(cacheDir, conf.Server.Plugins.CacheSize) + compilationCache, err = wazero.NewCompilationCacheWithDir(cacheDir) + }) + return compilationCache, err +} + +// newWazeroModuleConfig creates the correct ModuleConfig for plugins +func newWazeroModuleConfig() wazero.ModuleConfig { + return wazero.NewModuleConfig().WithStartFunctions("_initialize").WithStderr(log.Writer()) +} + +// pluginCompilationTimeout returns the timeout for plugin compilation +func pluginCompilationTimeout() time.Duration { + if conf.Server.DevPluginCompilationTimeout > 0 { + return conf.Server.DevPluginCompilationTimeout + } + return time.Minute +} + +// precompilePlugin compiles the WASM module in the background and updates the pluginState. +func precompilePlugin(p *plugin) { + compileSemaphore <- struct{}{} + defer func() { <-compileSemaphore }() + ctx := context.Background() + r, err := p.Runtime(ctx) + if err != nil { + p.compilationErr = fmt.Errorf("failed to create runtime for plugin %s: %w", p.ID, err) + close(p.compilationReady) + return + } + + b, err := os.ReadFile(p.WasmPath) + if err != nil { + p.compilationErr = fmt.Errorf("failed to read wasm file: %w", err) + close(p.compilationReady) + return + } + + // We know r is always a *scopedRuntime from createRuntime + scopedRT := r.(*scopedRuntime) + cachingRT := scopedRT.GetCachingRuntime() + if cachingRT == nil { + p.compilationErr = fmt.Errorf("failed to get cachingRuntime for plugin %s", p.ID) + close(p.compilationReady) + return + } + + _, err = cachingRT.CompileModule(ctx, b) + if err != nil { + p.compilationErr = fmt.Errorf("failed to compile WASM for plugin %s: %w", p.ID, err) + log.Warn("Plugin compilation failed", "name", p.ID, "path", p.WasmPath, "err", err) + } else { + p.compilationErr = nil + log.Debug("Plugin compilation completed", "name", p.ID, "path", p.WasmPath) + } + close(p.compilationReady) +} + +// loadHostLibrary loads the given host library and returns its exported functions +func loadHostLibrary[S any]( + ctx context.Context, + instantiateFn func(context.Context, wazero.Runtime, S) error, + service S, +) (map[string]wazeroapi.FunctionDefinition, error) { + r := wazero.NewRuntime(ctx) + if err := instantiateFn(ctx, r, service); err != nil { + return nil, err + } + m := r.Module("env") + return m.ExportedFunctionDefinitions(), nil +} + +// combineLibraries combines the given host libraries into a single "env" module +func combineLibraries(ctx context.Context, r wazero.Runtime, libs ...map[string]wazeroapi.FunctionDefinition) error { + // Merge the libraries + hostLib := map[string]wazeroapi.FunctionDefinition{} + for _, lib := range libs { + maps.Copy(hostLib, lib) + } + + // Create the combined host module + envBuilder := r.NewHostModuleBuilder("env") + for name, fd := range hostLib { + fn, ok := fd.GoFunction().(wazeroapi.GoModuleFunction) + if !ok { + return fmt.Errorf("invalid function definition: %s", fd.DebugName()) + } + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(fn, fd.ParamTypes(), fd.ResultTypes()). + WithParameterNames(fd.ParamNames()...).Export(name) + } + + // Instantiate the combined host module + if _, err := envBuilder.Instantiate(ctx); err != nil { + return err + } + return nil +} + +const ( + // WASM Instance pool configuration + // defaultPoolSize is the maximum number of instances per plugin that are kept in the pool for reuse + defaultPoolSize = 8 + // defaultInstanceTTL is the time after which an instance is considered stale and can be evicted + defaultInstanceTTL = time.Minute + // defaultMaxConcurrentInstances is the hard limit on total instances that can exist simultaneously + defaultMaxConcurrentInstances = 10 + // defaultGetTimeout is the maximum time to wait when getting an instance if at the concurrent limit + defaultGetTimeout = 5 * time.Second + + // Compiled module cache configuration + // defaultCompiledModuleTTL is the time after which a compiled module is evicted from the cache + defaultCompiledModuleTTL = 5 * time.Minute +) + +// cachedCompiledModule encapsulates a compiled WebAssembly module with TTL management +type cachedCompiledModule struct { + module wazero.CompiledModule + hash [16]byte + lastAccess time.Time + timer *time.Timer + mu sync.Mutex + pluginID string // for logging purposes +} + +// newCachedCompiledModule creates a new cached compiled module with TTL management +func newCachedCompiledModule(module wazero.CompiledModule, wasmBytes []byte, pluginID string) *cachedCompiledModule { + c := &cachedCompiledModule{ + module: module, + hash: md5.Sum(wasmBytes), + lastAccess: time.Now(), + pluginID: pluginID, + } + + // Set up the TTL timer + c.timer = time.AfterFunc(defaultCompiledModuleTTL, c.evict) + + return c +} + +// get returns the cached module if the hash matches, nil otherwise +// Also resets the TTL timer on successful access +func (c *cachedCompiledModule) get(wasmHash [16]byte) wazero.CompiledModule { + c.mu.Lock() // Use write lock because we modify state in resetTimer + defer c.mu.Unlock() + + if c.module != nil && c.hash == wasmHash { + // Reset TTL timer on access + c.resetTimer() + return c.module + } + + return nil +} + +// resetTimer resets the TTL timer (must be called with lock held) +func (c *cachedCompiledModule) resetTimer() { + c.lastAccess = time.Now() + + if c.timer != nil { + c.timer.Stop() + c.timer = time.AfterFunc(defaultCompiledModuleTTL, c.evict) + } +} + +// evict removes the cached module and cleans up resources +func (c *cachedCompiledModule) evict() { + c.mu.Lock() + defer c.mu.Unlock() + + if c.module != nil { + log.Trace("cachedCompiledModule: evicting due to TTL expiry", "plugin", c.pluginID, "ttl", defaultCompiledModuleTTL) + c.module.Close(context.Background()) + c.module = nil + c.hash = [16]byte{} + c.lastAccess = time.Time{} + } + + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } +} + +// close cleans up the cached module and stops the timer +func (c *cachedCompiledModule) close(ctx context.Context) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.timer != nil { + c.timer.Stop() + c.timer = nil + } + + if c.module != nil { + c.module.Close(ctx) + c.module = nil + } +} + +// pooledModule wraps a wazero Module and returns it to the pool when closed. +type pooledModule struct { + wazeroapi.Module + pool *wasmInstancePool[wazeroapi.Module] + closed bool +} + +func (m *pooledModule) Close(ctx context.Context) error { + if !m.closed { + m.closed = true + m.pool.Put(ctx, m.Module) + } + return nil +} + +func (m *pooledModule) CloseWithExitCode(ctx context.Context, exitCode uint32) error { + return m.Close(ctx) +} + +func (m *pooledModule) IsClosed() bool { + return m.closed +} + +// newScopedRuntime creates a new scopedRuntime that wraps the given runtime +func newScopedRuntime(runtime wazero.Runtime) *scopedRuntime { + return &scopedRuntime{Runtime: runtime} +} + +// scopedRuntime wraps a cachingRuntime and captures a specific module +// so that Close() only affects that module, not the entire shared runtime +type scopedRuntime struct { + wazero.Runtime + capturedModule wazeroapi.Module +} + +func (w *scopedRuntime) InstantiateModule(ctx context.Context, code wazero.CompiledModule, config wazero.ModuleConfig) (wazeroapi.Module, error) { + module, err := w.Runtime.InstantiateModule(ctx, code, config) + if err != nil { + return nil, err + } + // Capture the module for later cleanup + w.capturedModule = module + log.Trace(ctx, "scopedRuntime: captured module", "moduleID", getInstanceID(module)) + return module, nil +} + +func (w *scopedRuntime) Close(ctx context.Context) error { + // Close only the captured module, not the entire runtime + if w.capturedModule != nil { + log.Trace(ctx, "scopedRuntime: closing captured module", "moduleID", getInstanceID(w.capturedModule)) + return w.capturedModule.Close(ctx) + } + log.Trace(ctx, "scopedRuntime: no captured module to close") + return nil +} + +func (w *scopedRuntime) CloseWithExitCode(ctx context.Context, exitCode uint32) error { + return w.Close(ctx) +} + +// GetCachingRuntime returns the underlying cachingRuntime for internal use +func (w *scopedRuntime) GetCachingRuntime() *cachingRuntime { + if cr, ok := w.Runtime.(*cachingRuntime); ok { + return cr + } + return nil +} + +// cachingRuntime wraps wazero.Runtime and pools module instances per plugin, +// while also caching the compiled module in memory. +type cachingRuntime struct { + wazero.Runtime + + // pluginID is required to differentiate between different plugins that use the same file to initialize their + // runtime. The runtime will serve as a singleton for all instances of a given plugin. + pluginID string + + // cachedModule manages the compiled module cache with TTL + cachedModule atomic.Pointer[cachedCompiledModule] + + // pool manages reusable module instances + pool *wasmInstancePool[wazeroapi.Module] + + // poolInitOnce ensures the pool is initialized only once + poolInitOnce sync.Once + + // compilationMu ensures only one compilation happens at a time per runtime + compilationMu sync.Mutex +} + +func newCachingRuntime(runtime wazero.Runtime, pluginID string) *cachingRuntime { + return &cachingRuntime{ + Runtime: runtime, + pluginID: pluginID, + } +} + +func (r *cachingRuntime) initPool(code wazero.CompiledModule, config wazero.ModuleConfig) { + r.poolInitOnce.Do(func() { + r.pool = newWasmInstancePool[wazeroapi.Module](r.pluginID, defaultPoolSize, defaultMaxConcurrentInstances, defaultGetTimeout, defaultInstanceTTL, func(ctx context.Context) (wazeroapi.Module, error) { + log.Trace(ctx, "cachingRuntime: creating new module instance", "plugin", r.pluginID) + return r.Runtime.InstantiateModule(ctx, code, config) + }) + }) +} + +func (r *cachingRuntime) InstantiateModule(ctx context.Context, code wazero.CompiledModule, config wazero.ModuleConfig) (wazeroapi.Module, error) { + r.initPool(code, config) + mod, err := r.pool.Get(ctx) + if err != nil { + return nil, err + } + wrapped := &pooledModule{Module: mod, pool: r.pool} + log.Trace(ctx, "cachingRuntime: created wrapper for module", "plugin", r.pluginID, "underlyingModuleID", fmt.Sprintf("%p", mod), "wrapperID", fmt.Sprintf("%p", wrapped)) + return wrapped, nil +} + +func (r *cachingRuntime) Close(ctx context.Context) error { + log.Trace(ctx, "cachingRuntime: closing runtime", "plugin", r.pluginID) + + // Clean up compiled module cache + if cached := r.cachedModule.Swap(nil); cached != nil { + cached.close(ctx) + } + + // Close the instance pool + if r.pool != nil { + r.pool.Close(ctx) + } + // Close the underlying runtime + return r.Runtime.Close(ctx) +} + +// setCachedModule stores a newly compiled module in the cache with TTL management +func (r *cachingRuntime) setCachedModule(module wazero.CompiledModule, wasmBytes []byte) { + newCached := newCachedCompiledModule(module, wasmBytes, r.pluginID) + + // Replace old cached module and clean it up + if old := r.cachedModule.Swap(newCached); old != nil { + old.close(context.Background()) + } +} + +// CompileModule checks if the provided bytes match our cached hash and returns +// the cached compiled module if so, avoiding both file read and compilation. +func (r *cachingRuntime) CompileModule(ctx context.Context, wasmBytes []byte) (wazero.CompiledModule, error) { + incomingHash := md5.Sum(wasmBytes) + + // Try to get from cache first (without lock for performance) + if cached := r.cachedModule.Load(); cached != nil { + if module := cached.get(incomingHash); module != nil { + log.Trace(ctx, "cachingRuntime: using cached compiled module", "plugin", r.pluginID) + return module, nil + } + } + + // Synchronize compilation to prevent concurrent compilation issues + r.compilationMu.Lock() + defer r.compilationMu.Unlock() + + // Double-check cache after acquiring lock (another goroutine might have compiled it) + if cached := r.cachedModule.Load(); cached != nil { + if module := cached.get(incomingHash); module != nil { + log.Trace(ctx, "cachingRuntime: using cached compiled module (after lock)", "plugin", r.pluginID) + return module, nil + } + } + + // Fall back to normal compilation for different bytes + log.Trace(ctx, "cachingRuntime: hash doesn't match cache, compiling normally", "plugin", r.pluginID) + module, err := r.Runtime.CompileModule(ctx, wasmBytes) + if err != nil { + return nil, err + } + + // Cache the newly compiled module + r.setCachedModule(module, wasmBytes) + + return module, nil +} diff --git a/plugins/runtime_test.go b/plugins/runtime_test.go new file mode 100644 index 0000000..05efe1d --- /dev/null +++ b/plugins/runtime_test.go @@ -0,0 +1,173 @@ +package plugins + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/plugins/schema" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/tetratelabs/wazero" +) + +var _ = Describe("Runtime", func() { + Describe("pluginCompilationTimeout", func() { + It("should use DevPluginCompilationTimeout config for plugin compilation timeout", func() { + originalTimeout := conf.Server.DevPluginCompilationTimeout + DeferCleanup(func() { + conf.Server.DevPluginCompilationTimeout = originalTimeout + }) + + conf.Server.DevPluginCompilationTimeout = 123 * time.Second + Expect(pluginCompilationTimeout()).To(Equal(123 * time.Second)) + + conf.Server.DevPluginCompilationTimeout = 0 + Expect(pluginCompilationTimeout()).To(Equal(time.Minute)) + }) + }) +}) + +var _ = Describe("CachingRuntime", func() { + var ( + ctx context.Context + mgr *managerImpl + plugin *wasmScrobblerPlugin + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + mgr = createManager(nil, metrics.NewNoopInstance()) + // Add permissions for the test plugin using typed struct + permissions := schema.PluginManifestPermissions{ + Http: &schema.PluginManifestPermissionsHttp{ + Reason: "For testing HTTP functionality", + AllowedUrls: map[string][]schema.PluginManifestPermissionsHttpAllowedUrlsValueElem{ + "*": {schema.PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard}, + }, + AllowLocalNetwork: false, + }, + Config: &schema.PluginManifestPermissionsConfig{ + Reason: "For testing config functionality", + }, + } + rtFunc := mgr.createRuntime("fake_scrobbler", permissions) + plugin = newWasmScrobblerPlugin( + filepath.Join(testDataDir, "fake_scrobbler", "plugin.wasm"), + "fake_scrobbler", + mgr, + rtFunc, + wazero.NewModuleConfig().WithStartFunctions("_initialize"), + ).(*wasmScrobblerPlugin) + // runtime will be created on first plugin load + }) + + It("reuses module instances across calls", func() { + // First call to create the runtime and pool + _, done, err := plugin.getInstance(ctx, "first") + Expect(err).ToNot(HaveOccurred()) + done() + + val, ok := runtimePool.Load("fake_scrobbler") + Expect(ok).To(BeTrue()) + cachingRT := val.(*cachingRuntime) + + // Verify the pool exists and is initialized + Expect(cachingRT.pool).ToNot(BeNil()) + + // Test that multiple calls work without error (indicating pool reuse) + for i := 0; i < 5; i++ { + inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("call_%d", i)) + Expect(err).ToNot(HaveOccurred()) + Expect(inst).ToNot(BeNil()) + done() + } + + // Test concurrent access to verify pool handles concurrency + const numGoroutines = 3 + errChan := make(chan error, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + go func(id int) { + inst, done, err := plugin.getInstance(ctx, fmt.Sprintf("concurrent_%d", id)) + if err != nil { + errChan <- err + return + } + defer done() + + // Verify we got a valid instance + if inst == nil { + errChan <- fmt.Errorf("got nil instance") + return + } + errChan <- nil + }(i) + } + + // Check all goroutines succeeded + for i := 0; i < numGoroutines; i++ { + err := <-errChan + Expect(err).To(BeNil()) + } + }) +}) + +var _ = Describe("purgeCacheBySize", func() { + var tmpDir string + + BeforeEach(func() { + var err error + tmpDir, err = os.MkdirTemp("", "cache_test") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(os.RemoveAll, tmpDir) + }) + + It("removes oldest entries when above the size limit", func() { + oldDir := filepath.Join(tmpDir, "d1") + newDir := filepath.Join(tmpDir, "d2") + Expect(os.Mkdir(oldDir, 0700)).To(Succeed()) + Expect(os.Mkdir(newDir, 0700)).To(Succeed()) + + oldFile := filepath.Join(oldDir, "old") + newFile := filepath.Join(newDir, "new") + Expect(os.WriteFile(oldFile, []byte("xx"), 0600)).To(Succeed()) + Expect(os.WriteFile(newFile, []byte("xx"), 0600)).To(Succeed()) + + oldTime := time.Now().Add(-2 * time.Hour) + Expect(os.Chtimes(oldFile, oldTime, oldTime)).To(Succeed()) + + purgeCacheBySize(tmpDir, "3") + + _, err := os.Stat(oldFile) + Expect(os.IsNotExist(err)).To(BeTrue()) + _, err = os.Stat(oldDir) + Expect(os.IsNotExist(err)).To(BeTrue()) + + _, err = os.Stat(newFile) + Expect(err).ToNot(HaveOccurred()) + }) + + It("does nothing when below the size limit", func() { + dir1 := filepath.Join(tmpDir, "a") + dir2 := filepath.Join(tmpDir, "b") + Expect(os.Mkdir(dir1, 0700)).To(Succeed()) + Expect(os.Mkdir(dir2, 0700)).To(Succeed()) + + file1 := filepath.Join(dir1, "f1") + file2 := filepath.Join(dir2, "f2") + Expect(os.WriteFile(file1, []byte("x"), 0600)).To(Succeed()) + Expect(os.WriteFile(file2, []byte("x"), 0600)).To(Succeed()) + + purgeCacheBySize(tmpDir, "10MB") + + _, err := os.Stat(file1) + Expect(err).ToNot(HaveOccurred()) + _, err = os.Stat(file2) + Expect(err).ToNot(HaveOccurred()) + }) +}) diff --git a/plugins/schema/manifest.schema.json b/plugins/schema/manifest.schema.json new file mode 100644 index 0000000..0c32312 --- /dev/null +++ b/plugins/schema/manifest.schema.json @@ -0,0 +1,199 @@ +{ + "$id": "navidrome://plugins/manifest", + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Navidrome Plugin Manifest", + "description": "Schema for Navidrome Plugin manifest.json files", + "type": "object", + "required": [ + "name", + "author", + "version", + "description", + "website", + "capabilities", + "permissions" + ], + "properties": { + "name": { + "type": "string", + "description": "Name of the plugin" + }, + "author": { + "type": "string", + "description": "Author or organization that created the plugin" + }, + "version": { + "type": "string", + "description": "Plugin version using semantic versioning format" + }, + "description": { + "type": "string", + "description": "A brief description of the plugin's functionality" + }, + "website": { + "type": "string", + "format": "uri", + "description": "Website URL for the plugin or its documentation" + }, + "capabilities": { + "type": "array", + "description": "List of capabilities implemented by this plugin", + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "MetadataAgent", + "Scrobbler", + "SchedulerCallback", + "LifecycleManagement", + "WebSocketCallback" + ] + } + }, + "permissions": { + "type": "object", + "description": "Host services the plugin is allowed to access", + "additionalProperties": true, + "properties": { + "http": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "HTTP service permissions", + "required": ["allowedUrls"], + "properties": { + "allowedUrls": { + "type": "object", + "description": "Map of URL patterns (e.g., 'https://api.example.com/*') to allowed HTTP methods. Redirect destinations must also be included.", + "additionalProperties": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + "*" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "minProperties": 1 + }, + "allowLocalNetwork": { + "type": "boolean", + "description": "Whether to allow requests to local/private network addresses", + "default": false + } + } + } + ] + }, + "config": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Configuration service permissions" + } + ] + }, + "scheduler": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Scheduler service permissions" + } + ] + }, + "websocket": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "WebSocket service permissions", + "required": ["allowedUrls"], + "properties": { + "allowedUrls": { + "type": "array", + "description": "List of WebSocket URL patterns that the plugin is allowed to connect to", + "items": { + "type": "string", + "pattern": "^wss?://.*$" + }, + "minItems": 1, + "uniqueItems": true + }, + "allowLocalNetwork": { + "type": "boolean", + "description": "Whether to allow connections to local/private network addresses", + "default": false + } + } + } + ] + }, + "cache": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Cache service permissions" + } + ] + }, + "artwork": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "Artwork service permissions" + } + ] + }, + "subsonicapi": { + "allOf": [ + { "$ref": "#/$defs/basePermission" }, + { + "type": "object", + "description": "SubsonicAPI service permissions", + "properties": { + "allowedUsernames": { + "type": "array", + "description": "List of usernames the plugin can pass as u. Any user if empty", + "items": { "type": "string" } + }, + "allowAdmins": { + "type": "boolean", + "description": "If false, reject calls where the u is an admin", + "default": false + } + } + } + ] + } + } + } + }, + "$defs": { + "basePermission": { + "type": "object", + "required": ["reason"], + "properties": { + "reason": { + "type": "string", + "minLength": 1, + "description": "Explanation of why this permission is needed" + } + }, + "additionalProperties": false + } + } +} diff --git a/plugins/schema/manifest_gen.go b/plugins/schema/manifest_gen.go new file mode 100644 index 0000000..97e07a0 --- /dev/null +++ b/plugins/schema/manifest_gen.go @@ -0,0 +1,426 @@ +// Code generated by github.com/atombender/go-jsonschema, DO NOT EDIT. + +package schema + +import "encoding/json" +import "fmt" +import "reflect" + +type BasePermission struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *BasePermission) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in BasePermission: required") + } + type Plain BasePermission + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = BasePermission(plain) + return nil +} + +// Schema for Navidrome Plugin manifest.json files +type PluginManifest struct { + // Author or organization that created the plugin + Author string `json:"author" yaml:"author" mapstructure:"author"` + + // List of capabilities implemented by this plugin + Capabilities []PluginManifestCapabilitiesElem `json:"capabilities" yaml:"capabilities" mapstructure:"capabilities"` + + // A brief description of the plugin's functionality + Description string `json:"description" yaml:"description" mapstructure:"description"` + + // Name of the plugin + Name string `json:"name" yaml:"name" mapstructure:"name"` + + // Host services the plugin is allowed to access + Permissions PluginManifestPermissions `json:"permissions" yaml:"permissions" mapstructure:"permissions"` + + // Plugin version using semantic versioning format + Version string `json:"version" yaml:"version" mapstructure:"version"` + + // Website URL for the plugin or its documentation + Website string `json:"website" yaml:"website" mapstructure:"website"` +} + +type PluginManifestCapabilitiesElem string + +const PluginManifestCapabilitiesElemLifecycleManagement PluginManifestCapabilitiesElem = "LifecycleManagement" +const PluginManifestCapabilitiesElemMetadataAgent PluginManifestCapabilitiesElem = "MetadataAgent" +const PluginManifestCapabilitiesElemSchedulerCallback PluginManifestCapabilitiesElem = "SchedulerCallback" +const PluginManifestCapabilitiesElemScrobbler PluginManifestCapabilitiesElem = "Scrobbler" +const PluginManifestCapabilitiesElemWebSocketCallback PluginManifestCapabilitiesElem = "WebSocketCallback" + +var enumValues_PluginManifestCapabilitiesElem = []interface{}{ + "MetadataAgent", + "Scrobbler", + "SchedulerCallback", + "LifecycleManagement", + "WebSocketCallback", +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestCapabilitiesElem) UnmarshalJSON(value []byte) error { + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + var ok bool + for _, expected := range enumValues_PluginManifestCapabilitiesElem { + if reflect.DeepEqual(v, expected) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_PluginManifestCapabilitiesElem, v) + } + *j = PluginManifestCapabilitiesElem(v) + return nil +} + +// Host services the plugin is allowed to access +type PluginManifestPermissions struct { + // Artwork corresponds to the JSON schema field "artwork". + Artwork *PluginManifestPermissionsArtwork `json:"artwork,omitempty" yaml:"artwork,omitempty" mapstructure:"artwork,omitempty"` + + // Cache corresponds to the JSON schema field "cache". + Cache *PluginManifestPermissionsCache `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"` + + // Config corresponds to the JSON schema field "config". + Config *PluginManifestPermissionsConfig `json:"config,omitempty" yaml:"config,omitempty" mapstructure:"config,omitempty"` + + // Http corresponds to the JSON schema field "http". + Http *PluginManifestPermissionsHttp `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` + + // Scheduler corresponds to the JSON schema field "scheduler". + Scheduler *PluginManifestPermissionsScheduler `json:"scheduler,omitempty" yaml:"scheduler,omitempty" mapstructure:"scheduler,omitempty"` + + // Subsonicapi corresponds to the JSON schema field "subsonicapi". + Subsonicapi *PluginManifestPermissionsSubsonicapi `json:"subsonicapi,omitempty" yaml:"subsonicapi,omitempty" mapstructure:"subsonicapi,omitempty"` + + // Websocket corresponds to the JSON schema field "websocket". + Websocket *PluginManifestPermissionsWebsocket `json:"websocket,omitempty" yaml:"websocket,omitempty" mapstructure:"websocket,omitempty"` + + AdditionalProperties interface{} `mapstructure:",remain"` +} + +// Artwork service permissions +type PluginManifestPermissionsArtwork struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsArtwork) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsArtwork: required") + } + type Plain PluginManifestPermissionsArtwork + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsArtwork(plain) + return nil +} + +// Cache service permissions +type PluginManifestPermissionsCache struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsCache) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsCache: required") + } + type Plain PluginManifestPermissionsCache + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsCache(plain) + return nil +} + +// Configuration service permissions +type PluginManifestPermissionsConfig struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsConfig) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsConfig: required") + } + type Plain PluginManifestPermissionsConfig + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsConfig(plain) + return nil +} + +// HTTP service permissions +type PluginManifestPermissionsHttp struct { + // Whether to allow requests to local/private network addresses + AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty" yaml:"allowLocalNetwork,omitempty" mapstructure:"allowLocalNetwork,omitempty"` + + // Map of URL patterns (e.g., 'https://api.example.com/*') to allowed HTTP + // methods. Redirect destinations must also be included. + AllowedUrls map[string][]PluginManifestPermissionsHttpAllowedUrlsValueElem `json:"allowedUrls" yaml:"allowedUrls" mapstructure:"allowedUrls"` + + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +type PluginManifestPermissionsHttpAllowedUrlsValueElem string + +const PluginManifestPermissionsHttpAllowedUrlsValueElemDELETE PluginManifestPermissionsHttpAllowedUrlsValueElem = "DELETE" +const PluginManifestPermissionsHttpAllowedUrlsValueElemGET PluginManifestPermissionsHttpAllowedUrlsValueElem = "GET" +const PluginManifestPermissionsHttpAllowedUrlsValueElemHEAD PluginManifestPermissionsHttpAllowedUrlsValueElem = "HEAD" +const PluginManifestPermissionsHttpAllowedUrlsValueElemOPTIONS PluginManifestPermissionsHttpAllowedUrlsValueElem = "OPTIONS" +const PluginManifestPermissionsHttpAllowedUrlsValueElemPATCH PluginManifestPermissionsHttpAllowedUrlsValueElem = "PATCH" +const PluginManifestPermissionsHttpAllowedUrlsValueElemPOST PluginManifestPermissionsHttpAllowedUrlsValueElem = "POST" +const PluginManifestPermissionsHttpAllowedUrlsValueElemPUT PluginManifestPermissionsHttpAllowedUrlsValueElem = "PUT" +const PluginManifestPermissionsHttpAllowedUrlsValueElemWildcard PluginManifestPermissionsHttpAllowedUrlsValueElem = "*" + +var enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem = []interface{}{ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + "*", +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsHttpAllowedUrlsValueElem) UnmarshalJSON(value []byte) error { + var v string + if err := json.Unmarshal(value, &v); err != nil { + return err + } + var ok bool + for _, expected := range enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem { + if reflect.DeepEqual(v, expected) { + ok = true + break + } + } + if !ok { + return fmt.Errorf("invalid value (expected one of %#v): %#v", enumValues_PluginManifestPermissionsHttpAllowedUrlsValueElem, v) + } + *j = PluginManifestPermissionsHttpAllowedUrlsValueElem(v) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsHttp) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["allowedUrls"]; raw != nil && !ok { + return fmt.Errorf("field allowedUrls in PluginManifestPermissionsHttp: required") + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsHttp: required") + } + type Plain PluginManifestPermissionsHttp + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if v, ok := raw["allowLocalNetwork"]; !ok || v == nil { + plain.AllowLocalNetwork = false + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsHttp(plain) + return nil +} + +// Scheduler service permissions +type PluginManifestPermissionsScheduler struct { + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsScheduler) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsScheduler: required") + } + type Plain PluginManifestPermissionsScheduler + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsScheduler(plain) + return nil +} + +// SubsonicAPI service permissions +type PluginManifestPermissionsSubsonicapi struct { + // If false, reject calls where the u is an admin + AllowAdmins bool `json:"allowAdmins,omitempty" yaml:"allowAdmins,omitempty" mapstructure:"allowAdmins,omitempty"` + + // List of usernames the plugin can pass as u. Any user if empty + AllowedUsernames []string `json:"allowedUsernames,omitempty" yaml:"allowedUsernames,omitempty" mapstructure:"allowedUsernames,omitempty"` + + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsSubsonicapi) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsSubsonicapi: required") + } + type Plain PluginManifestPermissionsSubsonicapi + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if v, ok := raw["allowAdmins"]; !ok || v == nil { + plain.AllowAdmins = false + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsSubsonicapi(plain) + return nil +} + +// WebSocket service permissions +type PluginManifestPermissionsWebsocket struct { + // Whether to allow connections to local/private network addresses + AllowLocalNetwork bool `json:"allowLocalNetwork,omitempty" yaml:"allowLocalNetwork,omitempty" mapstructure:"allowLocalNetwork,omitempty"` + + // List of WebSocket URL patterns that the plugin is allowed to connect to + AllowedUrls []string `json:"allowedUrls" yaml:"allowedUrls" mapstructure:"allowedUrls"` + + // Explanation of why this permission is needed + Reason string `json:"reason" yaml:"reason" mapstructure:"reason"` +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifestPermissionsWebsocket) UnmarshalJSON(value []byte) error { + var raw map[string]interface{} + if err := json.Unmarshal(value, &raw); err != nil { + return err + } + if _, ok := raw["allowedUrls"]; raw != nil && !ok { + return fmt.Errorf("field allowedUrls in PluginManifestPermissionsWebsocket: required") + } + if _, ok := raw["reason"]; raw != nil && !ok { + return fmt.Errorf("field reason in PluginManifestPermissionsWebsocket: required") + } + type Plain PluginManifestPermissionsWebsocket + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if v, ok := raw["allowLocalNetwork"]; !ok || v == nil { + plain.AllowLocalNetwork = false + } + if plain.AllowedUrls != nil && len(plain.AllowedUrls) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "allowedUrls", 1) + } + if len(plain.Reason) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "reason", 1) + } + *j = PluginManifestPermissionsWebsocket(plain) + return nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (j *PluginManifest) 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 PluginManifest: required") + } + if _, ok := raw["capabilities"]; raw != nil && !ok { + return fmt.Errorf("field capabilities in PluginManifest: required") + } + if _, ok := raw["description"]; raw != nil && !ok { + return fmt.Errorf("field description in PluginManifest: required") + } + if _, ok := raw["name"]; raw != nil && !ok { + return fmt.Errorf("field name in PluginManifest: required") + } + if _, ok := raw["permissions"]; raw != nil && !ok { + return fmt.Errorf("field permissions in PluginManifest: required") + } + if _, ok := raw["version"]; raw != nil && !ok { + return fmt.Errorf("field version in PluginManifest: required") + } + if _, ok := raw["website"]; raw != nil && !ok { + return fmt.Errorf("field website in PluginManifest: required") + } + type Plain PluginManifest + var plain Plain + if err := json.Unmarshal(value, &plain); err != nil { + return err + } + if plain.Capabilities != nil && len(plain.Capabilities) < 1 { + return fmt.Errorf("field %s length: must be >= %d", "capabilities", 1) + } + *j = PluginManifest(plain) + return nil +} diff --git a/plugins/testdata/.gitignore b/plugins/testdata/.gitignore new file mode 100644 index 0000000..917660a --- /dev/null +++ b/plugins/testdata/.gitignore @@ -0,0 +1 @@ +*.wasm \ No newline at end of file diff --git a/plugins/testdata/Makefile b/plugins/testdata/Makefile new file mode 100644 index 0000000..f569cfc --- /dev/null +++ b/plugins/testdata/Makefile @@ -0,0 +1,10 @@ +# Fake sample plugins used for testing +PLUGINS := fake_album_agent fake_artist_agent fake_scrobbler multi_plugin fake_init_service unauthorized_plugin + +all: $(PLUGINS:%=%/plugin.wasm) + +clean: + rm -f $(PLUGINS:%=%/plugin.wasm) + +%/plugin.wasm: %/plugin.go + GOOS=wasip1 GOARCH=wasm go build -buildmode=c-shared -o $@ ./$* \ No newline at end of file diff --git a/plugins/testdata/README.md b/plugins/testdata/README.md new file mode 100644 index 0000000..abe840f --- /dev/null +++ b/plugins/testdata/README.md @@ -0,0 +1,17 @@ +# Plugin Test Data + +This directory contains test data and mock implementations used for testing the Navidrome plugin system. + +## Contents + +Each of these directories contains the source code for a simple Go plugin that implements a specific agent interface +(or multiple interfaces in the case of `multi_plugin`). These are compiled into WASM modules using the +`Makefile` and used in integration tests for the plugin adapters (e.g., `adapter_media_agent_test.go`). + +Running `make` within this directory will build all test plugins. + +## Usage + +The primary use of this directory is during the development and testing phase. The `Makefile` is used to build the +necessary WASM plugin binaries. The tests within the `plugins` package (and potentially other packages that interact +with plugins) then utilize these compiled plugins and other test fixtures found here. diff --git a/plugins/testdata/fake_album_agent/manifest.json b/plugins/testdata/fake_album_agent/manifest.json new file mode 100644 index 0000000..e8dfb1f --- /dev/null +++ b/plugins/testdata/fake_album_agent/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_album_agent", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for album agent", + "website": "https://test.navidrome.org/fake-album-agent", + "capabilities": ["MetadataAgent"], + "permissions": {} +} diff --git a/plugins/testdata/fake_album_agent/plugin.go b/plugins/testdata/fake_album_agent/plugin.go new file mode 100644 index 0000000..c35e903 --- /dev/null +++ b/plugins/testdata/fake_album_agent/plugin.go @@ -0,0 +1,70 @@ +//go:build wasip1 + +package main + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/api" +) + +type FakeAlbumAgent struct{} + +var ErrNotFound = api.ErrNotFound + +func (FakeAlbumAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + if req.Name != "" && req.Artist != "" { + return &api.AlbumInfoResponse{ + Info: &api.AlbumInfo{ + Name: req.Name, + Mbid: "album-mbid-123", + Description: "This is a test album description", + Url: "https://example.com/album", + }, + }, nil + } + return nil, ErrNotFound +} + +func (FakeAlbumAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + if req.Name != "" && req.Artist != "" { + return &api.AlbumImagesResponse{ + Images: []*api.ExternalImage{ + {Url: "https://example.com/album1.jpg", Size: 300}, + {Url: "https://example.com/album2.jpg", Size: 400}, + }, + }, nil + } + return nil, ErrNotFound +} + +func (FakeAlbumAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeAlbumAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, api.ErrNotImplemented +} + +func main() {} + +// Register the plugin implementation +func init() { + api.RegisterMetadataAgent(FakeAlbumAgent{}) +} diff --git a/plugins/testdata/fake_artist_agent/manifest.json b/plugins/testdata/fake_artist_agent/manifest.json new file mode 100644 index 0000000..c5db725 --- /dev/null +++ b/plugins/testdata/fake_artist_agent/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_artist_agent", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for artist agent", + "website": "https://test.navidrome.org/fake-artist-agent", + "capabilities": ["MetadataAgent"], + "permissions": {} +} diff --git a/plugins/testdata/fake_artist_agent/plugin.go b/plugins/testdata/fake_artist_agent/plugin.go new file mode 100644 index 0000000..bd6b0f7 --- /dev/null +++ b/plugins/testdata/fake_artist_agent/plugin.go @@ -0,0 +1,82 @@ +//go:build wasip1 + +package main + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/api" +) + +type FakeArtistAgent struct{} + +var ErrNotFound = api.ErrNotFound + +func (FakeArtistAgent) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + if req.Name != "" { + return &api.ArtistMBIDResponse{Mbid: "1234567890"}, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + if req.Name != "" { + return &api.ArtistURLResponse{Url: "https://example.com"}, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + if req.Name != "" { + return &api.ArtistBiographyResponse{Biography: "This is a test biography"}, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + if req.Name != "" { + return &api.ArtistSimilarResponse{ + Artists: []*api.Artist{ + {Name: "Similar Artist 1", Mbid: "mbid1"}, + {Name: "Similar Artist 2", Mbid: "mbid2"}, + }, + }, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + if req.Name != "" { + return &api.ArtistImageResponse{ + Images: []*api.ExternalImage{ + {Url: "https://example.com/image1.jpg", Size: 100}, + {Url: "https://example.com/image2.jpg", Size: 200}, + }, + }, nil + } + return nil, ErrNotFound +} +func (FakeArtistAgent) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + if req.ArtistName != "" { + return &api.ArtistTopSongsResponse{ + Songs: []*api.Song{ + {Name: "Song 1", Mbid: "mbid1"}, + {Name: "Song 2", Mbid: "mbid2"}, + }, + }, nil + } + return nil, ErrNotFound +} + +// Add empty implementations for the album methods to satisfy the MetadataAgent interface +func (FakeArtistAgent) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + return nil, api.ErrNotImplemented +} + +func (FakeArtistAgent) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return nil, api.ErrNotImplemented +} + +// main is required by Go WASI build +func main() {} + +// init is used by go-plugin to register the implementation +func init() { + api.RegisterMetadataAgent(FakeArtistAgent{}) +} diff --git a/plugins/testdata/fake_init_service/manifest.json b/plugins/testdata/fake_init_service/manifest.json new file mode 100644 index 0000000..ea8c45f --- /dev/null +++ b/plugins/testdata/fake_init_service/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_init_service", + "version": "1.0.0", + "capabilities": ["LifecycleManagement"], + "author": "Test Author", + "description": "Test LifecycleManagement Callback", + "website": "https://test.navidrome.org/fake-init-service", + "permissions": {} +} diff --git a/plugins/testdata/fake_init_service/plugin.go b/plugins/testdata/fake_init_service/plugin.go new file mode 100644 index 0000000..9e61716 --- /dev/null +++ b/plugins/testdata/fake_init_service/plugin.go @@ -0,0 +1,42 @@ +//go:build wasip1 + +package main + +import ( + "context" + "errors" + "log" + + "github.com/navidrome/navidrome/plugins/api" +) + +type initServicePlugin struct{} + +func (p *initServicePlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("OnInit called with %v", req) + + // Check for specific error conditions in the config + if req.Config != nil { + if errorType, exists := req.Config["returnError"]; exists { + switch errorType { + case "go_error": + return nil, errors.New("initialization failed with Go error") + case "response_error": + return &api.InitResponse{ + Error: "initialization failed with response error", + }, nil + } + } + } + + // Default: successful initialization + return &api.InitResponse{}, nil +} + +// Required by Go WASI build +func main() {} + +// Register the LifecycleManagement implementation +func init() { + api.RegisterLifecycleManagement(&initServicePlugin{}) +} diff --git a/plugins/testdata/fake_scrobbler/manifest.json b/plugins/testdata/fake_scrobbler/manifest.json new file mode 100644 index 0000000..6fa41aa --- /dev/null +++ b/plugins/testdata/fake_scrobbler/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "fake_scrobbler", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for scrobbler", + "website": "https://test.navidrome.org/fake-scrobbler", + "capabilities": ["Scrobbler"], + "permissions": {} +} diff --git a/plugins/testdata/fake_scrobbler/plugin.go b/plugins/testdata/fake_scrobbler/plugin.go new file mode 100644 index 0000000..5a5c766 --- /dev/null +++ b/plugins/testdata/fake_scrobbler/plugin.go @@ -0,0 +1,33 @@ +//go:build wasip1 + +package main + +import ( + "context" + "log" + + "github.com/navidrome/navidrome/plugins/api" +) + +type FakeScrobbler struct{} + +func (FakeScrobbler) IsAuthorized(ctx context.Context, req *api.ScrobblerIsAuthorizedRequest) (*api.ScrobblerIsAuthorizedResponse, error) { + log.Printf("[FakeScrobbler] IsAuthorized called for user: %s (%s)", req.Username, req.UserId) + return &api.ScrobblerIsAuthorizedResponse{Authorized: true}, nil +} + +func (FakeScrobbler) NowPlaying(ctx context.Context, req *api.ScrobblerNowPlayingRequest) (*api.ScrobblerNowPlayingResponse, error) { + log.Printf("[FakeScrobbler] NowPlaying called for user: %s (%s), track: %s", req.Username, req.UserId, req.Track.Name) + return &api.ScrobblerNowPlayingResponse{}, nil +} + +func (FakeScrobbler) Scrobble(ctx context.Context, req *api.ScrobblerScrobbleRequest) (*api.ScrobblerScrobbleResponse, error) { + log.Printf("[FakeScrobbler] Scrobble called for user: %s (%s), track: %s, timestamp: %d", req.Username, req.UserId, req.Track.Name, req.Timestamp) + return &api.ScrobblerScrobbleResponse{}, nil +} + +func main() {} + +func init() { + api.RegisterScrobbler(FakeScrobbler{}) +} diff --git a/plugins/testdata/multi_plugin/manifest.json b/plugins/testdata/multi_plugin/manifest.json new file mode 100644 index 0000000..dc9e0a9 --- /dev/null +++ b/plugins/testdata/multi_plugin/manifest.json @@ -0,0 +1,13 @@ +{ + "name": "multi_plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test data for multiple services", + "website": "https://test.navidrome.org/multi-plugin", + "capabilities": ["MetadataAgent", "SchedulerCallback", "LifecycleManagement"], + "permissions": { + "scheduler": { + "reason": "For testing scheduled callback functionality" + } + } +} diff --git a/plugins/testdata/multi_plugin/plugin.go b/plugins/testdata/multi_plugin/plugin.go new file mode 100644 index 0000000..3c28bd2 --- /dev/null +++ b/plugins/testdata/multi_plugin/plugin.go @@ -0,0 +1,124 @@ +//go:build wasip1 + +package main + +import ( + "context" + "log" + "strings" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/scheduler" +) + +// MultiPlugin implements the MetadataAgent interface for testing +type MultiPlugin struct{} + +var ErrNotFound = api.ErrNotFound + +var sched = scheduler.NewSchedulerService() + +// Artist-related methods +func (MultiPlugin) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + if req.Name != "" { + return &api.ArtistMBIDResponse{Mbid: "multi-artist-mbid"}, nil + } + return nil, ErrNotFound +} + +func (MultiPlugin) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + log.Printf("GetArtistURL received: %v", req) + + // Use an ID that could potentially clash with other plugins + // The host will ensure this doesn't conflict by prefixing with plugin name + customId := "artist:" + req.Name + log.Printf("Registering scheduler with custom ID: %s", customId) + + // Use the scheduler service for one-time scheduling + resp, err := sched.ScheduleOneTime(ctx, &scheduler.ScheduleOneTimeRequest{ + ScheduleId: customId, + DelaySeconds: 6, + Payload: []byte("test-payload"), + }) + if err != nil { + log.Printf("Error scheduling one-time job: %v", err) + } else { + log.Printf("One-time schedule registered with ID: %s", resp.ScheduleId) + } + + return &api.ArtistURLResponse{Url: "https://multi.example.com/artist"}, nil +} + +func (MultiPlugin) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return &api.ArtistBiographyResponse{Biography: "Multi agent artist bio"}, nil +} + +func (MultiPlugin) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return &api.ArtistSimilarResponse{}, nil +} + +func (MultiPlugin) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return &api.ArtistImageResponse{}, nil +} + +func (MultiPlugin) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return &api.ArtistTopSongsResponse{}, nil +} + +// Album-related methods +func (MultiPlugin) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + if req.Name != "" && req.Artist != "" { + return &api.AlbumInfoResponse{ + Info: &api.AlbumInfo{ + Name: req.Name, + Mbid: "multi-album-mbid", + Description: "Multi agent album description", + Url: "https://multi.example.com/album", + }, + }, nil + } + return nil, ErrNotFound +} + +func (MultiPlugin) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return &api.AlbumImagesResponse{}, nil +} + +// Scheduler callback +func (MultiPlugin) OnSchedulerCallback(ctx context.Context, req *api.SchedulerCallbackRequest) (*api.SchedulerCallbackResponse, error) { + log.Printf("Scheduler callback received with ID: %s, payload: '%s', isRecurring: %v", + req.ScheduleId, string(req.Payload), req.IsRecurring) + + // Demonstrate how to parse the custom ID format + if strings.HasPrefix(req.ScheduleId, "artist:") { + parts := strings.Split(req.ScheduleId, ":") + if len(parts) == 2 { + artistName := parts[1] + log.Printf("This schedule was for artist: %s", artistName) + } + } + + return &api.SchedulerCallbackResponse{}, nil +} + +func (MultiPlugin) OnInit(ctx context.Context, req *api.InitRequest) (*api.InitResponse, error) { + log.Printf("OnInit called with %v", req) + + // Schedule a recurring every 5 seconds + _, _ = sched.ScheduleRecurring(ctx, &scheduler.ScheduleRecurringRequest{ + CronExpression: "@every 5s", + Payload: []byte("every 5 seconds"), + }) + + return &api.InitResponse{}, nil +} + +// Required by Go WASI build +func main() {} + +// Register the service implementations +func init() { + api.RegisterLifecycleManagement(MultiPlugin{}) + api.RegisterMetadataAgent(MultiPlugin{}) + api.RegisterSchedulerCallback(MultiPlugin{}) +} diff --git a/plugins/testdata/unauthorized_plugin/manifest.json b/plugins/testdata/unauthorized_plugin/manifest.json new file mode 100644 index 0000000..38a00e0 --- /dev/null +++ b/plugins/testdata/unauthorized_plugin/manifest.json @@ -0,0 +1,9 @@ +{ + "name": "unauthorized_plugin", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "Test plugin that tries to access unauthorized services", + "website": "https://test.navidrome.org/unauthorized-plugin", + "capabilities": ["MetadataAgent"], + "permissions": {} +} diff --git a/plugins/testdata/unauthorized_plugin/plugin.go b/plugins/testdata/unauthorized_plugin/plugin.go new file mode 100644 index 0000000..07c3e0f --- /dev/null +++ b/plugins/testdata/unauthorized_plugin/plugin.go @@ -0,0 +1,78 @@ +//go:build wasip1 + +package main + +import ( + "context" + + "github.com/navidrome/navidrome/plugins/api" + "github.com/navidrome/navidrome/plugins/host/http" +) + +type UnauthorizedPlugin struct{} + +var ErrNotFound = api.ErrNotFound + +func (UnauthorizedPlugin) GetAlbumInfo(ctx context.Context, req *api.AlbumInfoRequest) (*api.AlbumInfoResponse, error) { + // This plugin attempts to make an HTTP call without having HTTP permission + // This should fail since the plugin has no permissions in its manifest + httpClient := http.NewHttpService() + + request := &http.HttpRequest{ + Url: "https://example.com/test", + Headers: map[string]string{ + "Accept": "application/json", + }, + TimeoutMs: 5000, + } + + _, err := httpClient.Get(ctx, request) + if err != nil { + // Expected to fail due to missing permission + return nil, err + } + + return &api.AlbumInfoResponse{ + Info: &api.AlbumInfo{ + Name: req.Name, + Mbid: "unauthorized-test", + Description: "This should not work", + Url: "https://example.com/unauthorized", + }, + }, nil +} + +func (UnauthorizedPlugin) GetAlbumImages(ctx context.Context, req *api.AlbumImagesRequest) (*api.AlbumImagesResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistMBID(ctx context.Context, req *api.ArtistMBIDRequest) (*api.ArtistMBIDResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistURL(ctx context.Context, req *api.ArtistURLRequest) (*api.ArtistURLResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistBiography(ctx context.Context, req *api.ArtistBiographyRequest) (*api.ArtistBiographyResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetSimilarArtists(ctx context.Context, req *api.ArtistSimilarRequest) (*api.ArtistSimilarResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistImages(ctx context.Context, req *api.ArtistImageRequest) (*api.ArtistImageResponse, error) { + return nil, api.ErrNotImplemented +} + +func (UnauthorizedPlugin) GetArtistTopSongs(ctx context.Context, req *api.ArtistTopSongsRequest) (*api.ArtistTopSongsResponse, error) { + return nil, api.ErrNotImplemented +} + +func main() {} + +// Register the plugin implementation +func init() { + api.RegisterMetadataAgent(UnauthorizedPlugin{}) +} diff --git a/plugins/wasm_instance_pool.go b/plugins/wasm_instance_pool.go new file mode 100644 index 0000000..5ea1a82 --- /dev/null +++ b/plugins/wasm_instance_pool.go @@ -0,0 +1,223 @@ +package plugins + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/navidrome/navidrome/log" +) + +// wasmInstancePool is a generic pool using channels for simplicity and Go idioms +type wasmInstancePool[T any] struct { + name string + new func(ctx context.Context) (T, error) + poolSize int + getTimeout time.Duration + ttl time.Duration + + mu sync.RWMutex + instances chan poolItem[T] + semaphore chan struct{} + closing chan struct{} + closed bool +} + +type poolItem[T any] struct { + value T + created time.Time +} + +func newWasmInstancePool[T any](name string, poolSize int, maxConcurrentInstances int, getTimeout time.Duration, ttl time.Duration, newFn func(ctx context.Context) (T, error)) *wasmInstancePool[T] { + p := &wasmInstancePool[T]{ + name: name, + new: newFn, + poolSize: poolSize, + getTimeout: getTimeout, + ttl: ttl, + instances: make(chan poolItem[T], poolSize), + semaphore: make(chan struct{}, maxConcurrentInstances), + closing: make(chan struct{}), + } + + // Fill semaphore to allow maxConcurrentInstances + for i := 0; i < maxConcurrentInstances; i++ { + p.semaphore <- struct{}{} + } + + log.Debug(context.Background(), "wasmInstancePool: created new pool", "pool", p.name, "poolSize", p.poolSize, "maxConcurrentInstances", maxConcurrentInstances, "getTimeout", p.getTimeout, "ttl", p.ttl) + go p.cleanupLoop() + return p +} + +func getInstanceID(inst any) string { + return fmt.Sprintf("%p", inst) //nolint:govet +} + +func (p *wasmInstancePool[T]) Get(ctx context.Context) (T, error) { + // First acquire a semaphore slot (concurrent limit) + select { + case <-p.semaphore: + // Got slot, continue + case <-ctx.Done(): + var zero T + return zero, ctx.Err() + case <-time.After(p.getTimeout): + var zero T + return zero, fmt.Errorf("timeout waiting for available instance after %v", p.getTimeout) + case <-p.closing: + var zero T + return zero, fmt.Errorf("pool is closing") + } + + // Try to get from pool first + p.mu.RLock() + instances := p.instances + p.mu.RUnlock() + + select { + case item := <-instances: + log.Trace(ctx, "wasmInstancePool: got instance from pool", "pool", p.name, "instanceID", getInstanceID(item.value)) + return item.value, nil + default: + // Pool empty, create new instance + instance, err := p.new(ctx) + if err != nil { + // Failed to create, return semaphore slot + log.Trace(ctx, "wasmInstancePool: failed to create new instance", "pool", p.name, err) + p.semaphore <- struct{}{} + var zero T + return zero, err + } + log.Trace(ctx, "wasmInstancePool: new instance created", "pool", p.name, "instanceID", getInstanceID(instance)) + return instance, nil + } +} + +func (p *wasmInstancePool[T]) Put(ctx context.Context, v T) { + p.mu.RLock() + instances := p.instances + closed := p.closed + p.mu.RUnlock() + + if closed { + log.Trace(ctx, "wasmInstancePool: pool closed, closing instance", "pool", p.name, "instanceID", getInstanceID(v)) + p.closeItem(ctx, v) + // Return semaphore slot only if this instance came from Get() + select { + case p.semaphore <- struct{}{}: + case <-p.closing: + default: + // Semaphore full, this instance didn't come from Get() + } + return + } + + // Try to return to pool + item := poolItem[T]{value: v, created: time.Now()} + select { + case instances <- item: + log.Trace(ctx, "wasmInstancePool: returned instance to pool", "pool", p.name, "instanceID", getInstanceID(v)) + default: + // Pool full, close instance + log.Trace(ctx, "wasmInstancePool: pool full, closing instance", "pool", p.name, "instanceID", getInstanceID(v)) + p.closeItem(ctx, v) + } + + // Return semaphore slot only if this instance came from Get() + // If semaphore is full, this instance didn't come from Get(), so don't block + select { + case p.semaphore <- struct{}{}: + // Successfully returned token + case <-p.closing: + // Pool closing, don't block + default: + // Semaphore full, this instance didn't come from Get() + } +} + +func (p *wasmInstancePool[T]) Close(ctx context.Context) { + p.mu.Lock() + if p.closed { + p.mu.Unlock() + return + } + p.closed = true + close(p.closing) + instances := p.instances + p.mu.Unlock() + + log.Trace(ctx, "wasmInstancePool: closing pool and all instances", "pool", p.name) + + // Drain and close all instances + for { + select { + case item := <-instances: + p.closeItem(ctx, item.value) + default: + return + } + } +} + +func (p *wasmInstancePool[T]) cleanupLoop() { + ticker := time.NewTicker(p.ttl / 3) + defer ticker.Stop() + for { + select { + case <-ticker.C: + p.cleanupExpired() + case <-p.closing: + return + } + } +} + +func (p *wasmInstancePool[T]) cleanupExpired() { + ctx := context.Background() + now := time.Now() + + // Create new channel with same capacity + newInstances := make(chan poolItem[T], p.poolSize) + + // Atomically swap channels + p.mu.Lock() + oldInstances := p.instances + p.instances = newInstances + p.mu.Unlock() + + // Drain old channel, keeping fresh items + var expiredCount int + for { + select { + case item := <-oldInstances: + if now.Sub(item.created) <= p.ttl { + // Item is still fresh, move to new channel + select { + case newInstances <- item: + // Successfully moved + default: + // New channel full, close excess item + p.closeItem(ctx, item.value) + } + } else { + // Item expired, close it + expiredCount++ + p.closeItem(ctx, item.value) + } + default: + // Old channel drained + if expiredCount > 0 { + log.Trace(ctx, "wasmInstancePool: cleaned up expired instances", "pool", p.name, "expiredCount", expiredCount) + } + return + } + } +} + +func (p *wasmInstancePool[T]) closeItem(ctx context.Context, v T) { + if closer, ok := any(v).(interface{ Close(context.Context) error }); ok { + _ = closer.Close(ctx) + } +} diff --git a/plugins/wasm_instance_pool_test.go b/plugins/wasm_instance_pool_test.go new file mode 100644 index 0000000..1412104 --- /dev/null +++ b/plugins/wasm_instance_pool_test.go @@ -0,0 +1,193 @@ +package plugins + +import ( + "context" + "sync/atomic" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type testInstance struct { + closed atomic.Bool +} + +func (t *testInstance) Close(ctx context.Context) error { + t.closed.Store(true) + return nil +} + +var _ = Describe("wasmInstancePool", func() { + var ( + ctx = context.Background() + ) + + It("should Get and Put instances", func() { + pool := newWasmInstancePool[*testInstance]("test", 2, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst, err := pool.Get(ctx) + Expect(err).To(BeNil()) + Expect(inst).ToNot(BeNil()) + pool.Put(ctx, inst) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + Expect(inst2).To(Equal(inst)) + pool.Close(ctx) + }) + + It("should not exceed max instances", func() { + pool := newWasmInstancePool[*testInstance]("test", 1, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2 := &testInstance{} + pool.Put(ctx, inst1) + pool.Put(ctx, inst2) // should close inst2 + Expect(inst2.closed.Load()).To(BeTrue()) + pool.Close(ctx) + }) + + It("should expire and close instances after TTL", func() { + pool := newWasmInstancePool[*testInstance]("test", 2, 10, 5*time.Second, 100*time.Millisecond, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst, err := pool.Get(ctx) + Expect(err).To(BeNil()) + pool.Put(ctx, inst) + // Wait for TTL cleanup + time.Sleep(300 * time.Millisecond) + Expect(inst.closed.Load()).To(BeTrue()) + pool.Close(ctx) + }) + + It("should close all on pool Close", func() { + pool := newWasmInstancePool[*testInstance]("test", 2, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + pool.Put(ctx, inst1) + pool.Put(ctx, inst2) + pool.Close(ctx) + Expect(inst1.closed.Load()).To(BeTrue()) + Expect(inst2.closed.Load()).To(BeTrue()) + }) + + It("should be safe for concurrent Get/Put", func() { + pool := newWasmInstancePool[*testInstance]("test", 4, 10, 5*time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + done := make(chan struct{}) + for i := 0; i < 8; i++ { + go func() { + inst, err := pool.Get(ctx) + Expect(err).To(BeNil()) + pool.Put(ctx, inst) + done <- struct{}{} + }() + } + for i := 0; i < 8; i++ { + <-done + } + pool.Close(ctx) + }) + + It("should enforce max concurrent instances limit", func() { + callCount := atomic.Int32{} + pool := newWasmInstancePool[*testInstance]("test", 2, 3, 100*time.Millisecond, time.Second, func(ctx context.Context) (*testInstance, error) { + callCount.Add(1) + return &testInstance{}, nil + }) + + // Get 3 instances (should hit the limit) + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst3, err := pool.Get(ctx) + Expect(err).To(BeNil()) + + // Should have created exactly 3 instances at this point + Expect(callCount.Load()).To(Equal(int32(3))) + + // Fourth call should timeout without creating a new instance + start := time.Now() + _, err = pool.Get(ctx) + duration := time.Since(start) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("timeout waiting for available instance")) + Expect(duration).To(BeNumerically(">=", 100*time.Millisecond)) + Expect(duration).To(BeNumerically("<", 200*time.Millisecond)) + + // Still should have only 3 instances (timeout didn't create new one) + Expect(callCount.Load()).To(Equal(int32(3))) + + // Return one instance and try again - should succeed by reusing returned instance + pool.Put(ctx, inst1) + inst4, err := pool.Get(ctx) + Expect(err).To(BeNil()) + Expect(inst4).To(Equal(inst1)) // Should be the same instance we returned + + // Still should have only 3 instances total (reused inst1) + Expect(callCount.Load()).To(Equal(int32(3))) + + pool.Put(ctx, inst2) + pool.Put(ctx, inst3) + pool.Put(ctx, inst4) + pool.Close(ctx) + }) + + It("should handle concurrent waiters properly", func() { + pool := newWasmInstancePool[*testInstance]("test", 1, 2, time.Second, time.Second, func(ctx context.Context) (*testInstance, error) { + return &testInstance{}, nil + }) + + // Fill up the concurrent slots + inst1, err := pool.Get(ctx) + Expect(err).To(BeNil()) + inst2, err := pool.Get(ctx) + Expect(err).To(BeNil()) + + // Start multiple waiters + waiterResults := make(chan error, 3) + for i := 0; i < 3; i++ { + go func() { + _, err := pool.Get(ctx) + waiterResults <- err + }() + } + + // Wait a bit to ensure waiters are queued + time.Sleep(50 * time.Millisecond) + + // Return instances one by one + pool.Put(ctx, inst1) + pool.Put(ctx, inst2) + + // Two waiters should succeed, one should timeout + successCount := 0 + timeoutCount := 0 + for i := 0; i < 3; i++ { + select { + case err := <-waiterResults: + if err == nil { + successCount++ + } else { + timeoutCount++ + } + case <-time.After(2 * time.Second): + Fail("Test timed out waiting for waiter results") + } + } + + Expect(successCount).To(Equal(2)) + Expect(timeoutCount).To(Equal(1)) + + pool.Close(ctx) + }) +}) diff --git a/reflex.conf b/reflex.conf new file mode 100644 index 0000000..4cd64ba --- /dev/null +++ b/reflex.conf @@ -0,0 +1 @@ +-s -r "(\.go$$|\.cpp$$|\.h$$|\.wasm$$|navidrome.toml|resources|token_received.html)" -R "(^ui|^data|^db/migrations)" -- go run -race -tags netgo . diff --git a/release/goreleaser.yml b/release/goreleaser.yml new file mode 100644 index 0000000..30c0d6f --- /dev/null +++ b/release/goreleaser.yml @@ -0,0 +1,154 @@ +# 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 + - 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/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 new file mode 100644 index 0000000..811c85f --- /dev/null +++ b/release/linux/.package.deb @@ -0,0 +1 @@ +deb \ No newline at end of file diff --git a/release/linux/.package.rpm b/release/linux/.package.rpm new file mode 100644 index 0000000..7c88ef3 --- /dev/null +++ b/release/linux/.package.rpm @@ -0,0 +1 @@ +rpm \ No newline at end of file diff --git a/release/linux/navidrome.toml b/release/linux/navidrome.toml new file mode 100644 index 0000000..e626c8c --- /dev/null +++ b/release/linux/navidrome.toml @@ -0,0 +1,2 @@ +DataFolder = "/var/lib/navidrome" +MusicFolder = "/opt/navidrome/music" diff --git a/release/linux/postinstall.sh b/release/linux/postinstall.sh new file mode 100644 index 0000000..f3d9c92 --- /dev/null +++ b/release/linux/postinstall.sh @@ -0,0 +1,28 @@ +#!/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 + +postinstall_flag="/var/lib/navidrome/.installed" + +if [ ! -f "$postinstall_flag" ]; then + # The primary reason why this would fail is if the service was already installed AND + # someone manually removed the .installed flag. In this case, ignore the error + navidrome service install --user navidrome --working-directory /var/lib/navidrome --configfile /etc/navidrome/navidrome.toml || : + # Any `navidrome` command will make a cache. Make sure that this is properly owned by the Navidrome user + # and not by root + chown navidrome:navidrome /var/lib/navidrome/cache + touch "$postinstall_flag" +fi + + diff --git a/release/linux/preinstall.sh b/release/linux/preinstall.sh new file mode 100755 index 0000000..aa5850e --- /dev/null +++ b/release/linux/preinstall.sh @@ -0,0 +1,6 @@ +#!/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 new file mode 100644 index 0000000..0dfcafe --- /dev/null +++ b/release/linux/preremove.sh @@ -0,0 +1,30 @@ +#!/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/wix/Navidrome_UI_Flow.wxs b/release/wix/Navidrome_UI_Flow.wxs new file mode 100644 index 0000000..59c2f51 --- /dev/null +++ b/release/wix/Navidrome_UI_Flow.wxs @@ -0,0 +1,41 @@ +<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 new file mode 100644 index 0000000..4a83f91 --- /dev/null +++ b/release/wix/SettingsDlg.wxs @@ -0,0 +1,44 @@ +<?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="INSTALLDIR" Text="[INSTALLDIR]" /> + + <!-- 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 new file mode 100644 index 0000000..bbaa0fc Binary files /dev/null and b/release/wix/bmp/banner.bmp differ diff --git a/release/wix/bmp/dialogue.bmp b/release/wix/bmp/dialogue.bmp new file mode 100644 index 0000000..56f3db5 Binary files /dev/null and b/release/wix/bmp/dialogue.bmp differ diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh new file mode 100755 index 0000000..7e59531 --- /dev/null +++ b/release/wix/build_msi.sh @@ -0,0 +1,63 @@ +#!/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" +cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.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 new file mode 100644 index 0000000..38364eb --- /dev/null +++ b/release/wix/msitools.dockerfile @@ -0,0 +1,3 @@ +FROM public.ecr.aws/docker/library/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 new file mode 100644 index 0000000..8ebba46 --- /dev/null +++ b/release/wix/navidrome.wxs @@ -0,0 +1,93 @@ +<?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'> + + <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" /> + <Property Id="ND_MUSICFOLDER" Value="C:\Music" /> + <Property Id="ND_DATAFOLDER" Value="C:\ProgramData\Navidrome" /> + + <UIRef Id="Navidrome_UI_Flow"/> + + <Directory Id='TARGETDIR' Name='SourceDir'> + <Directory Id="$(var.PlatformProgramFilesFolder)"> + <Directory Id='INSTALLDIR' Name='Navidrome'> + + <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="INSTALLDIR" Key="Port" Section="default" Value="'[ND_PORT]'" /> + <IniFile Id="ConfigurationMusicDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="MusicFolder" Section="default" Value="'[ND_MUSICFOLDER]'" /> + <IniFile Id="ConfigurationDataDir" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="DataFolder" Section="default" Value="'[ND_DATAFOLDER]'" /> + <IniFile Id="FFmpegPath" Name="navidrome.ini" Action="createLine" Directory="INSTALLDIR" Key="FFmpegPath" Section="default" Value="'[INSTALLDIR]ffmpeg.exe'" /> + </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 "[INSTALLDIR]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> + + </Directory> + </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> + + <InstallUISequence> + <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='PackageFile' /> + </Feature> + </Product> +</Wix> diff --git a/release/xxgo b/release/xxgo new file mode 100755 index 0000000..3cdacd8 --- /dev/null +++ b/release/xxgo @@ -0,0 +1,17 @@ +#!/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 new file mode 100644 index 0000000..ced0ade Binary files /dev/null and b/resources/album-placeholder.webp differ diff --git a/resources/artist-placeholder.webp b/resources/artist-placeholder.webp new file mode 100644 index 0000000..2729158 Binary files /dev/null and b/resources/artist-placeholder.webp differ diff --git a/resources/banner.go b/resources/banner.go new file mode 100644 index 0000000..0f7f1a5 --- /dev/null +++ b/resources/banner.go @@ -0,0 +1,24 @@ +package resources + +import ( + "fmt" + "io" + "strings" + "unicode" + + "github.com/navidrome/navidrome/consts" +) + +func loadBanner() string { + f, err := embedFS.Open("banner.txt") + if err != nil { + return "" + } + data, _ := io.ReadAll(f) + return strings.TrimRightFunc(string(data), unicode.IsSpace) +} + +func Banner() string { + version := "Version: " + consts.Version + return fmt.Sprintf("%s\n%52s\n", loadBanner(), version) +} diff --git a/resources/banner.txt b/resources/banner.txt new file mode 100644 index 0000000..cd6828e --- /dev/null +++ b/resources/banner.txt @@ -0,0 +1,6 @@ + _ _ _ _ +| \ | | (_) | | +| \| | __ ___ ___ __| |_ __ ___ _ __ ___ ___ +| . ` |/ _` \ \ / / |/ _` | '__/ _ \| '_ ` _ \ / _ \ +| |\ | (_| |\ V /| | (_| | | | (_) | | | | | | __/ +\_| \_/\__,_| \_/ |_|\__,_|_| \___/|_| |_| |_|\___| diff --git a/resources/embed.go b/resources/embed.go new file mode 100644 index 0000000..0386e6f --- /dev/null +++ b/resources/embed.go @@ -0,0 +1,21 @@ +package resources + +import ( + "embed" + "io/fs" + "os" + "path" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/utils/merge" +) + +//go:embed * +var embedFS embed.FS + +func FS() fs.FS { + return merge.FS{ + Base: embedFS, + Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")), + } +} diff --git a/resources/i18n/ar.json b/resources/i18n/ar.json new file mode 100644 index 0000000..d7902c1 --- /dev/null +++ b/resources/i18n/ar.json @@ -0,0 +1,460 @@ +{ + "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": "الحجم", + "originalDate": "", + "releaseDate": "", + "releases": "", + "released": "" + }, + "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": "هل يسمح بالتنزيل؟" + } + } + }, + "ra": { + "auth": { + "welcome1": "شكرا لتثبيت نافيدروم!", + "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": "يجب أن يطابق التنسيق المحدد (regexp): %{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": "حُدث العنصر |||| حُدث %{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": "يعمل نافيدروم حاليًا مع %{config}، مما يجعل من الممكن تشغيل أوامر النظام من إعدادات تحويل الترميز باستخدام واجهة الويب. نوصي بتعطيله لأسباب أمنية وتمكينه فقط عند ضبط خيارات تحويل الترميز.", + "songsAddedToPlaylist": "أضيفت صوتية لقائمة التشغيل |||| أضيف %{smart_count} صوتية لقائمة التشغيل", + "noPlaylistsAvailable": "غير موجودة", + "delete_user_title": "احذف اسم المستخدم '%{name}'", + "delete_user_content": "هل أنت متيقِّن من أنك تريد حذف هذا المستخدم وجميع بياناته (بما في ذلك قوائم التشغيل والتفضيلات)؟", + "notifications_blocked": "قد حظرت الإشعارات لهذا الموقع في إعدادات مستعرضك.", + "notifications_not_available": "لا يدعم هذا المستعرض إشعارات سطح المكتب أو أنك لا تصل إلى نافيدروم عبر https", + "lastfmLinkSuccess": "ربط حساب Last.fm بنجاح وفعل إرسال البيانات.", + "lastfmLinkFailure": "حساب Last.fm غير مربوط", + "lastfmUnlinkSuccess": "ألغي ربط Last.fm وعطل إرسال البيانات", + "lastfmUnlinkFailure": "تعذر فك ارتباط حساب Last.fm", + "openIn": { + "lastfm": "افتح في Last.fm", + "musicbrainz": "افتح في MusicBrainz" + }, + "lastfmLink": "اقرأ المزيد…", + "listenBrainzLinkSuccess": "ربط حساب ListenBrainz بنجاح ومكّن إرسال البينات باسم المستخدم: %{user}", + "listenBrainzLinkFailure": "تعذر ربط ListenBrainz : %{error}", + "listenBrainzUnlinkSuccess": "ألغي ربط ListenBrainz وعطل إرسال البيانات", + "listenBrainzUnlinkFailure": "حساب ListenBrainz غير مربوط", + "downloadOriginalFormat": "حمّل بالترميز الأصلي", + "shareOriginalFormat": "شارك بالترميز الأصلي", + "shareDialogTitle": "شارك %{resource} '%{name}'", + "shareBatchDialogTitle": "شارك %{resource} |||| شارك %{smart_count} %{resource}", + "shareSuccess": "نسخ الرابط للحافظة: %{url}", + "shareFailure": "حدث خطأ أثناء نسخ الرابط %{url} إلى الحافظة", + "downloadDialogTitle": "حمّل %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "انسخ إلى الحافظة: Ctrl+C، إدخال" + }, + "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": "قوائم التشغيل المشتركة" + }, + "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": "مفاتيح نافيدروم للتشغيل السريع", + "hotkeys": { + "show_help": "أظهر المساعدة", + "toggle_menu": "أظهر/أخفي القائمة الجانبي", + "toggle_play": "شغّل / ألبث", + "prev_song": "الصوتية السابقة", + "next_song": "الصوتية التالية", + "vol_up": "ارفع الصوت", + "vol_down": "أخفض الصوت", + "toggle_love": "أضف هذا المسار للمفضلة", + "current_song": "اذهب للصوتية الحالية" + } + } +} \ No newline at end of file diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json new file mode 100644 index 0000000..dfe3f27 --- /dev/null +++ b/resources/i18n/bg.json @@ -0,0 +1,634 @@ +{ + "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": "" + }, + "actions": { + "addToQueue": "Пусни по-късно", + "playNow": "Пусни сега", + "addToPlaylist": "Добави към плейлист", + "shuffleAll": "Разбъркай всички", + "download": "Свали", + "playNext": "Следваща", + "info": "Информация", + "showInPlaylist": "" + } + }, + "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": "", + "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": "", + "fullScan": "" + }, + "notifications": { + "created": "", + "updated": "", + "deleted": "", + "scanStarted": "", + "scanCompleted": "", + "quickScanStarted": "", + "fullScanStarted": "", + "scanError": "" + }, + "validation": { + "nameRequired": "", + "pathRequired": "", + "pathNotDirectory": "", + "pathNotFound": "", + "pathNotAccessible": "", + "pathInvalid": "" + }, + "messages": { + "deleteConfirm": "", + "scanInProgress": "", + "noLibrariesAssigned": "" + } + } + }, + "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": "Покажи паролата" + } + }, + "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": "" + }, + "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": "API ключът на Last.fm не е конфигуриран" + } + }, + "albumList": "Албуми", + "about": "Относно", + "playlists": "Плейлисти", + "sharedPlaylists": "Споделени плейлисти", + "librarySelector": { + "allLibraries": "", + "multipleLibraries": "", + "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": "Изчакване" + } + }, + "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": "Изминало време", + "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": "" + } +} \ No newline at end of file diff --git a/resources/i18n/bs.json b/resources/i18n/bs.json new file mode 100644 index 0000000..9d5c552 --- /dev/null +++ b/resources/i18n/bs.json @@ -0,0 +1,628 @@ +{ + "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 new file mode 100644 index 0000000..e3e7b54 --- /dev/null +++ b/resources/i18n/ca.json @@ -0,0 +1,518 @@ +{ + "languageName": "Català", + "resources": { + "song": { + "name": "Cançó |||| Cançons", + "fields": { + "albumArtist": "Artista de l'àlbum", + "duration": "Durada", + "trackNumber": "#", + "playCount": "Reproduccions", + "title": "Títol", + "artist": "Artista", + "album": "Àlbum", + "path": "Ruta del fitxer", + "genre": "Gènere", + "compilation": "Compilació", + "year": "Any", + "size": "Mida del fitxer", + "updatedAt": "Actualitzat", + "bitRate": "Taxa de bits", + "bitDepth": "Bits", + "sampleRate": "Freqüencia de mostreig", + "channels": "Canals", + "discSubtitle": "Subtítol del disc", + "starred": "Preferit", + "comment": "Comentari", + "rating": "Valoració", + "quality": "Qualitat", + "bpm": "tempo", + "playDate": "Darrer resproduït", + "createdAt": "Creat el", + "grouping": "Agrupació", + "mood": "Sentiment", + "participants": "Participants", + "tags": "Etiquetes", + "mappedTags": "Etiquetes assignades", + "rawTags": "Etiquetes sense processar" + }, + "actions": { + "addToQueue": "Reprodueix després", + "playNow": "Reprodueix ara", + "addToPlaylist": "Afegeix a la llista", + "shuffleAll": "Aleatori", + "download": "Descarrega", + "playNext": "Reprodueix següent", + "info": "Obtén informació" + } + }, + "album": { + "name": "Àlbum |||| Àlbums", + "fields": { + "albumArtist": "Artista de l'àlbum", + "artist": "Artista", + "duration": "Durada", + "songCount": "Cançons", + "playCount": "Reproduccions", + "size": "Mida", + "name": "Nom", + "genre": "Gènere", + "compilation": "Compilació", + "year": "Any", + "updatedAt": "Actualitzat ", + "comment": "Comentari", + "rating": "Valoració", + "createdAt": "Creat el", + "size": "Mida", + "originalDate": "Original", + "releaseDate": "Publicat", + "releases": "LLançament |||| Llançaments", + "released": "Publicat", + "recordLabel": "Discogràfica", + "catalogNum": "Número de catàleg", + "releaseType": "Tipus de publicació", + "grouping": "Agrupació", + "media": "Mitjà", + "mood": "Sentiment" + }, + "actions": { + "playAll": "Reprodueix", + "playNext": "Reprodueix la següent", + "addToQueue": "Reprodueix després", + "share": "Compartir", + "shuffle": "Aleatori", + "addToPlaylist": "Afegeix a la llista", + "download": "Descarrega", + "info": "Obtén informació" + }, + "lists": { + "all": "Tot", + "random": "Aleatori", + "recentlyAdded": "Afegit fa poc", + "recentlyPlayed": "Reproduït fa poc", + "mostPlayed": "Més reproduït", + "starred": "Preferits", + "topRated": "Més ben valorades" + } + }, + "artist": { + "name": "Artista |||| Artistes", + "fields": { + "name": "Nom", + "albumCount": "Nombre d'àlbums", + "songCount": "Nombre de cançons", + "size": "Mida", + "playCount": "Reproduccions", + "rating": "Valoració", + "genre": "Gènere", + "role": "Rol" + }, + "roles": { + "albumartist": "Artista de l'Àlbum |||| Artistes de l'Àlbum", + "artist": "Artista |||| Artistes", + "composer": "Compositor |||| Compositors", + "conductor": "Conductor |||| Conductors", + "lyricist": "Lletrista |||| Lletristes", + "arranger": "Arranjador |||| Arranjadors", + "producer": "Productor |||| Productors", + "director": "Director |||| Directors", + "engineer": "Enginyer |||| Enginyers", + "mixer": "Mesclador |||| Mescladors", + "remixer": "Remesclador |||| Remescladors", + "djmixer": "DJ Mesclador |||| DJ Mescladors", + "performer": "Intèrpret |||| Intèrprets" + } + }, + "user": { + "name": "Usuari |||| Usuaris", + "fields": { + "userName": "Nom d'usuari", + "isAdmin": "És admin", + "lastLoginAt": "Última connexió", + "lastAccessAt": "Últim Accés", + "updatedAt": "Actualitzat", + "name": "Nom", + "password": "Contrasenya", + "createdAt": "Creat", + "changePassword": "Canviar la contrasenya?", + "currentPassword": "Contrasenya actual", + "newPassword": "Contrasenya nova", + "token": "Token" + }, + "helperTexts": { + "name": "Els canvis en el nom s'hi aplicaran en la següent connexió" + }, + "notifications": { + "created": "Usuari creat", + "updated": "Usuari actualitzat", + "deleted": "Usuari eliminat" + }, + "message": { + "listenBrainzToken": "Introduïu el vostre token d'usuari de ListenBrainz", + "clickHereForToken": "Feu clic ací per a obtenir el vostre token" + } + }, + "player": { + "name": "Reproductor |||| Reproductors", + "fields": { + "name": "Nom", + "transcodingId": "Transcodificador", + "maxBitRate": "Taxa de bits màx.", + "client": "Client", + "userName": "Nom d'usuari", + "lastSeen": "Vist", + "reportRealPath": "Informa de la ruta real", + "scrobbleEnabled": "Activa el seguiment des de serveis externs" + } + }, + "transcoding": { + "name": "Transcodificador |||| Transcodificadors", + "fields": { + "name": "Nom", + "targetFormat": "Format desitjat", + "defaultBitRate": "Taxa de bits per defecte", + "command": "Ordre" + } + }, + "playlist": { + "name": "Llista |||| Llistes", + "fields": { + "name": "Nom", + "duration": "Durada", + "ownerName": "Propietari", + "public": "Públic", + "updatedAt": "Actualitzat ", + "createdAt": "Creat", + "songCount": "Cançons", + "comment": "Comentari", + "sync": "Auto-importació", + "path": "Importa de" + }, + "actions": { + "selectPlaylist": "Selecciona una llista:", + "addNewPlaylist": "Crea \"%{nom}", + "export": "Exporta", + "makePublic": "Fes públic", + "makePrivate": "Fes privat" + }, + "message": { + "duplicate_song": "Afegeix cançons duplicades", + "song_exist": "Heu afegit duplicats a la llista. Voleu afegir-los o ignorar-los?" + } + }, + "radio": { + "name": "Ràdio |||| Ràdios", + "fields": { + "name": "Nom", + "streamUrl": "URL del flux", + "homePageUrl": "URL principal", + "updatedAt": "Actualitzat", + "createdAt": "Creat" + }, + "actions": { + "playNow": "Reprodueix" + } + }, + "share": { + "name": "Compartir |||| Compartits", + "fields": { + "username": "Compartit per", + "url": "URL", + "description": "Descripció", + "downloadable": "Permet descarregar?", + "contents": "Continguts", + "expiresAt": "Caduca", + "lastVisitedAt": "Última Visita", + "visitCount": "Visites", + "format": "Format", + "maxBitRate": "Taxa de bits màx.", + "updatedAt": "Actualitzat", + "createdAt": "Creat" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "Fitxer faltant |||| Fitxers Faltants", + "empty": "No falten fitxers", + "fields": { + "path": "Directori", + "size": "Mida", + "updatedAt": "Desaparegut" + }, + "actions": { + "remove": "Eliminar" + }, + "notifications": { + "removed": "Fitxers faltants eliminats" + } + } + }, + "ra": { + "auth": { + "welcome1": "Gràcies d'haver instal·lat Navidrome!", + "welcome2": "Per a començar, creeu un usuari administrador", + "confirmPassword": "Confirmeu la contrasenya", + "buttonCreateAdmin": "Crea un administrador", + "auth_check_error": "Si us plau, inicieu sessió per a continuar", + "user_menu": "Perfil", + "username": "Nom d'usuari", + "password": "Contrasenya", + "sign_in": "Inicia sessió", + "sign_in_error": "L'autenticació ha fallat, torneu-ho a intentar", + "logout": "Sortida", + "insightsCollectionNote": "Navidrome recull dades d'us anonimitzades per\najudar a millorar el projecte. Clica [aquí] per a saber-ne\nmés i no participar-hi si no vols" + }, + "validation": { + "invalidChars": "Si us plau, useu només lletres i nombres", + "passwordDoesNotMatch": "Les contrasenyes no coincideixen", + "required": "Obligatori", + "minLength": "Ha de tenir, si més no, %{min} caràcters", + "maxLength": "Ha de tenir %{max} caràcters o menys", + "minValue": "Ha de ser com a mínim %{min}", + "maxValue": "Ha de ser %{max} o menys", + "number": "Ha de ser un nombre", + "email": "Ha de ser un correu vàlid", + "oneOf": "Ha de ser un de: %{options}", + "regex": "Ha de tenir el format (regexp): %{pattern}", + "unique": "Ha de ser únic", + "url": "Ha de ser una URL vàlida" + }, + "action": { + "add_filter": "Afegeix un filtre", + "add": "Afegeix", + "back": "Enrere", + "bulk_actions": "1 element seleccionat |||| %{smart_count} elements seleccionats", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "cancel": "Cancel·la", + "clear_input_value": "Neteja el valor", + "clone": "Clona", + "confirm": "Confirma", + "create": "Crea", + "delete": "Suprimeix", + "edit": "Edita", + "export": "Exporta", + "list": "Llista", + "refresh": "Refresca", + "remove_filter": "Suprimeix aquest filtre", + "remove": "Elimina", + "save": "Desa", + "search": "Cerca", + "show": "Mostra", + "sort": "Ordena", + "undo": "Desfés", + "expand": "Expandeix", + "close": "Tanca", + "open_menu": "Obre el menú", + "close_menu": "Tanca el menú", + "unselect": "Anul·la la selecció", + "skip": "Omet", + "share": "Compartir", + "download": "Descarregar" + }, + "boolean": { + "true": "Sí", + "false": "No" + }, + "page": { + "create": "Crea %{nom}", + "dashboard": "Tauler", + "edit": "%{name} #%{id}", + "error": "Alguna cosa ha fallat", + "list": "%{name}", + "loading": "Ara es carrega", + "not_found": "No s'ha trobat", + "show": "%{name} #%{id}", + "empty": "No hi ha %{name} encara.", + "invite": "Voleu afegir-ne una?" + }, + "input": { + "file": { + "upload_several": "Deixeu caure-hi fitxers per a carregar-los o feu clic per a seleccionar-ne un.", + "upload_single": "Deixeu caure-hi un fitxer per a carregar o feu clic per a seleccionar-lo." + }, + "image": { + "upload_several": "Deixeu caure-hi imatges per a carregar-les o feu clic per a seleccionar-ne una.", + "upload_single": "Deixeu caure-hi una imatge per a carregar-la o feu clic per a seleccionar-la." + }, + "references": { + "all_missing": "No ha estat possible trobar les dades de referència.", + "many_missing": "Sembla que almenys una de les referències associades ja no està disponible.", + "single_missing": "Sembla que la referència associada ja no està disponible." + }, + "password": { + "toggle_visible": "Amaga la contrasenya", + "toggle_hidden": "Mostra la contrasenya" + } + }, + "message": { + "about": "Quant a...", + "are_you_sure": "N'esteu segur?", + "bulk_delete_content": "Voleu eliminar aquest %{name}? |||| Voleu eliminar aquests %{smart_count} element?\n", + "bulk_delete_title": "Esborra %{name} |||| Esborra %{smart_count} %{name}", + "delete_content": "Segur que voleu eliminar aquest element?", + "delete_title": "Elimina %{name} #%{id}", + "details": "Detalls", + "error": "S'ha produït un error en un client i la vostra sol·licitud no ha pogut ser completada.", + "invalid_form": "El formulari no és vàlid.", + "loading": "La pàgina es carrega, un moment si us plau.", + "no": "No", + "not_found": "La URL és incorrecta o heu seguit un enllaç erroni.", + "yes": "Sí", + "unsaved_changes": "Alguns canvis no s'hi han desat. Segur que voleu ignorar-los?" + }, + "navigation": { + "no_results": "No s'ha trobat", + "no_more_results": "La pàgina número %{page} no existeix. Proveu l'anterior.", + "page_out_of_boundaries": "La pàgina número %{page} no existeix", + "page_out_from_end": "No podeu anar més enllà de la darrera pàgina", + "page_out_from_begin": "No podeu anar més enllà de la primera pàgina", + "page_range_info": "%{offsetBegin}-%{offsetEnd} de %{total}", + "page_rows_per_page": "Elements per pàgina:", + "next": "Següent", + "prev": "Anterior", + "skip_nav": "Salta al contingut" + }, + "notification": { + "updated": "Element actualitzat |||| %{smart_count} elements actualitzats", + "created": "Element creat", + "deleted": "Element actualitzat |||| %{smart_count} elements actualitzats", + "bad_item": "Element incorrecte", + "item_doesnt_exist": "L'element no existeix", + "http_error": "Error de comunicació del servidor", + "data_provider_error": "dataProvider error. Vegeu la consola si en voleu més detalls.", + "i18n_error": "No ha estat possible carregar les traduccions per a l'idioma indicat", + "canceled": "Acció cancel·lada", + "logged_out": "La sessió ha acabat, si us plau reconnecteu", + "new_version": "Hi ha una versió nova disponible! Si us plau actualitzeu aquesta finestra." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Columnes a mostrar", + "layout": "Disposició", + "grid": "Quadrícula", + "table": "Taula" + } + }, + "message": { + "note": "NOTA", + "transcodingDisabled": "Per motius de seguretat, el canvi de configuració del trasnscodificador amb la interfície web no està habilitat. Si voleu canviar les opcions de transcodificació (sia editar-les sia afegir-ne), reinicieu el servidor amb l'opció %{config}.", + "transcodingEnabled": "Ara Navidrome s'executa amb %{config}, cosa que fa possible executar ordres del sistema des de les opcions de transcodificació usant la interfície web. Per motius de seguretat us recomanem que només l'activeu quan necessiteu configurar les opcions de transcodificació.", + "songsAddedToPlaylist": "S'ha afegit 1 cançó a la llista |||| S'han afegit %{smart_count} a la llista", + "noPlaylistsAvailable": "No n'hi ha cap disponible", + "delete_user_title": "Esborra usuari '%{nom}'", + "delete_user_content": "Segur que voleu eliminar aquest usuari i les seues dades\n(incloent-hi llistes i preferències)", + "remove_missing_title": "Eliminar fitxers faltants", + "remove_missing_content": "Segur que vols eliminar els fitxers faltants seleccionats de la base de dades? Això eliminarà permanentment les referències a ells, incloent-hi el nombre de reproduccions i les valoracions.", + "notifications_blocked": "Heu blocat les notificacions d'escriptori en les preferències del navegador", + "notifications_not_available": "El navegador no suporta les notificacions o no heu connectat a Navidrome per https", + "lastfmLinkSuccess": "Ha reexit la vinculació amb Last.fm i se n'ha activat el seguiment", + "lastfmLinkFailure": "No ha estat possible la vinculació amb Last.fm", + "lastfmUnlinkSuccess": "Desvinculat de Last.fm i desactivat el seguiment", + "lastfmUnlinkFailure": "No s'ha pogut desvincular de Last.fm", + "listenBrainzLinkSuccess": "Connectat correctament a ListenBrainz i seguiment activat com a: %{user}", + "listenBrainzLinkFailure": "No s'ha pogut connectar a ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz desconnectat i seguiment desactivat", + "listenBrainzUnlinkFailure": "No s'ha pogut desconnectar de ListenBrainz", + "openIn": { + "lastfm": "Obri en Last.fm", + "musicbrainz": "Obri en MusicBrainz" + }, + "lastfmLink": "Llegeix més...", + "shareOriginalFormat": "Compartir en format original", + "shareDialogTitle": "Compartir %{resource} '%{name}'", + "shareBatchDialogTitle": "Compartir 1 %{resource} |||| Compartir %{smart_count} %{resource}", + "shareCopyToClipboard": "Copiar al porta-retalls: Ctrl+C, Enter", + "shareSuccess": "URL copiada al porta-retalls: %{url}", + "shareFailure": "Error copiant URL %{url} al porta-retalls", + "downloadDialogTitle": "Deascarregar %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Descarregar en format original" + }, + "menu": { + "library": "Discoteca", + "settings": "Configuració", + "version": "Versió", + "theme": "Tema", + "personal": { + "name": "Personal", + "options": { + "theme": "Tema", + "language": "Llengua", + "defaultView": "Vista per defecte", + "desktop_notifications": "Notificacions d'escriptori", + "lastfmNotConfigured": "No s'ha configurat l'API de Last.fm", + "lastfmScrobbling": "Activa el seguiment de Last.fm", + "listenBrainzScrobbling": "Activa el seguiment de ListenBrainz", + "replaygain": "Mode ReplayGain", + "preAmp": "PreAmp de ReplayGain (dB)", + "gain": { + "none": "Cap", + "album": "Guany de l'àlbum", + "track": "Guany de la pista" + } + } + }, + "albumList": "Àlbums", + "about": "Quant a...", + "playlists": "Llistes", + "sharedPlaylists": "Llistes compartides" + }, + "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·licitud de funcionalitats", + "lastInsightsCollection": "Última recolecció d'informació", + "insights": { + "disabled": "Desactivada", + "waiting": "Esperant" + } + } + }, + "activity": { + "title": "Activitat", + "totalScanned": "Carpetes escanejades en total", + "quickScan": "Escaneig ràpid", + "fullScan": "Escaneig complet", + "serverUptime": "Temps de funcionament del servidor", + "serverDown": "Sense connexió" + }, + "help": { + "title": "Dreceres de teclat de Navidrome", + "hotkeys": { + "show_help": "Mostra aquesta ajuda", + "toggle_menu": "Commuta la barra lateral", + "toggle_play": "Reprodueix / Pausa", + "prev_song": "Cançó anterior", + "next_song": "Cançó següent", + "vol_up": "Apuja el volum", + "vol_down": "Abaixa el volum", + "toggle_love": "Afegeix la pista a favorits", + "current_song": "Anar a la cançó actual" + } + } +} diff --git a/resources/i18n/cs.json b/resources/i18n/cs.json new file mode 100644 index 0000000..fc2cc95 --- /dev/null +++ b/resources/i18n/cs.json @@ -0,0 +1,460 @@ +{ + "languageName": "Čeština", + "resources": { + "song": { + "name": "Skladba |||| Skladby", + "fields": { + "albumArtist": "Interpret alba", + "duration": "Délka", + "trackNumber": "#", + "playCount": "Přehrání", + "title": "Název", + "artist": "Interpret", + "album": "Album", + "path": "Cesta k souboru", + "genre": "Žánr", + "compilation": "Kompilace", + "year": "Rok", + "size": "Velikost souboru", + "updatedAt": "Nahráno", + "bitRate": "Přenosová rychlost", + "discSubtitle": "Podtitul disku", + "starred": "Oblíbené", + "comment": "Komentář", + "rating": "Hodnocení", + "quality": "Kvalita", + "bpm": "BPM", + "playDate": "Poslední přehravaná skladba", + "channels": "Kanály", + "createdAt": "Přidáno" + }, + "actions": { + "addToQueue": "Přehrát později", + "playNow": "Přehrát nyní", + "addToPlaylist": "Přidat do seznamu skladeb", + "shuffleAll": "Zamíchat vše", + "download": "Stáhnout", + "playNext": "Přehrát další", + "info": "Získat informace" + } + }, + "album": { + "name": "Album |||| Alba", + "fields": { + "albumArtist": "Interpret alba", + "artist": "Interpret", + "duration": "Délka", + "songCount": "Skladby", + "playCount": "Přehrání", + "name": "Název", + "genre": "Žánr", + "compilation": "Kompilace", + "year": "Rok", + "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" + }, + "actions": { + "playAll": "Přehrát", + "playNext": "Přehrát další", + "addToQueue": "Přehrát později", + "shuffle": "Zamíchat", + "addToPlaylist": "Přidat do seznamu skladeb", + "download": "Stáhnout", + "info": "Získat informace", + "share": "Sdílet" + }, + "lists": { + "all": "Všechno", + "random": "Náhodné", + "recentlyAdded": "Nedávno přidané", + "recentlyPlayed": "Nedávno přehrané", + "mostPlayed": "Nejpřehrávanější", + "starred": "Oblíbené", + "topRated": "Nejlépe hodnocené" + } + }, + "artist": { + "name": "Interpret |||| Interpreti", + "fields": { + "name": "Název", + "albumCount": "Počet alb", + "songCount": "Počet skladeb", + "playCount": "Přehrání", + "rating": "Hodnocení", + "genre": "Žánr", + "size": "Velikost" + } + }, + "user": { + "name": "Uživatel |||| Uživatelé", + "fields": { + "userName": "Uživatelské jméno", + "isAdmin": "Správcem", + "lastLoginAt": "Naposledy přihlášen", + "updatedAt": "Upraven", + "name": "Jméno", + "password": "Heslo", + "createdAt": "Vytvořen", + "changePassword": "Změnit heslo?", + "currentPassword": "Současné heslo", + "newPassword": "Nové heslo", + "token": "Token" + }, + "helperTexts": { + "name": "Změna jména se zobrazí až při dalším přihlášení" + }, + "notifications": { + "created": "Uživatel vytvořen", + "updated": "Uživatel upraven", + "deleted": "Uživatel odstraněn" + }, + "message": { + "listenBrainzToken": "Vložte svůj uživatelský ListenBrainz token.", + "clickHereForToken": "Klikněte zde pro získání svého tokenu" + } + }, + "player": { + "name": "Přehrávač |||| Přehrávače", + "fields": { + "name": "Název", + "transcodingId": "ID překódování", + "maxBitRate": "Max. přenosová rychlost", + "client": "Klient", + "userName": "Uživatelské jméno", + "lastSeen": "Naposledy viděn", + "reportRealPath": "Zkutečná cesta hlášení", + "scrobbleEnabled": "Poslat scrobblování na externí služby" + } + }, + "transcoding": { + "name": "Překódování |||| Překódování", + "fields": { + "name": "Název", + "targetFormat": "Cílený formát", + "defaultBitRate": "Výchozí přenosová rychlost", + "command": "Příkaz" + } + }, + "playlist": { + "name": "Seznam skladeb |||| Seznamy skladeb", + "fields": { + "name": "Název", + "duration": "Délka", + "ownerName": "Autor", + "public": "Veřejný", + "updatedAt": "Nahrán", + "createdAt": "Vytvořen", + "songCount": "Skladby", + "comment": "Komentář", + "sync": "Auto-import", + "path": "Importovat z" + }, + "actions": { + "selectPlaylist": "Přidat skladby do seznamu:", + "addNewPlaylist": "Vytvořit \"%{name}\"", + "export": "Export", + "makePublic": "Zveřejnit", + "makePrivate": "Nastavit jako soukromé" + }, + "message": { + "duplicate_song": "Přidat duplicitní skladby", + "song_exist": "Do seznamu skladeb se přidávají duplikáty. Chcete je přidat nebo přeskočit?" + } + }, + "radio": { + "name": "Rádio |||| Rádia", + "fields": { + "name": "Název", + "streamUrl": "URL streamu", + "homePageUrl": "URL stránky", + "updatedAt": "Nahráno", + "createdAt": "Vytvořeno" + }, + "actions": { + "playNow": "Spustit" + } + }, + "share": { + "name": "Sdílení |||| Sdílení", + "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í?" + } + } + }, + "ra": { + "auth": { + "welcome1": "Děkujeme, že jste si nainstalovali Navidrome!", + "welcome2": "Nejdříve vytvořte účet správce", + "confirmPassword": "Potvrďte heslo", + "buttonCreateAdmin": "Vytvořit správce", + "auth_check_error": "Pro pokračování se prosím přihlašte", + "user_menu": "Profil", + "username": "Uživatelské jméno", + "password": "Heslo", + "sign_in": "Přihlásit se", + "sign_in_error": "Ověření selhalo, zkuste to znovu", + "logout": "Odhlásit se" + }, + "validation": { + "invalidChars": "Prosím, používejte pouze písmena a čísla", + "passwordDoesNotMatch": "Hesla se neschodují", + "required": "Povinné pole", + "minLength": "Musí obsahovat nejméně %{min} znaků", + "maxLength": "Může obsahovat maximálně %{max} znaků", + "minValue": "Musí být alespoň %{min}", + "maxValue": "Může být maximálně %{max}", + "number": "Musí být číslo", + "email": "Musí být platná emailová adresa", + "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" + }, + "action": { + "add_filter": "Přidat filtr", + "add": "Přidat", + "back": "Jít zpět", + "bulk_actions": "%{smart_count} vybráno", + "cancel": "Zrušit", + "clear_input_value": "Smazat hodnotu", + "clone": "Klonovat", + "confirm": "Potvrdit", + "create": "Vytvořit", + "delete": "Smazat", + "edit": "Upravit", + "export": "Exportovat", + "list": "Seznam", + "refresh": "Obnovit", + "remove_filter": "Odstranit filtr", + "remove": "Odstranit", + "save": "Uložit", + "search": "Vyhledat", + "show": "Ukázat", + "sort": "Seřadit", + "undo": "Vrátit", + "expand": "Zvětšit", + "close": "Zavřít", + "open_menu": "Otevřít nabídku", + "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" + }, + "boolean": { + "true": "Ano", + "false": "Ne" + }, + "page": { + "create": "Vytvořit %{name}", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Něco se pokazilo", + "list": "%{name}", + "loading": "Načítání", + "not_found": "Nenalezeno", + "show": "%{name} #%{id}", + "empty": "Zatím žádný %{name}.", + "invite": "Chcete jeden přidat?" + }, + "input": { + "file": { + "upload_several": "Přetáhněte soubory pro nahrání nebo klikněte pro výběr.", + "upload_single": "Přetáhněte soubor pro nahrání nebo klikněte pro jeho výběr." + }, + "image": { + "upload_several": "Přetáhněte obrázky pro nahrání nebo klikněte pro výběr.", + "upload_single": "Přetáhněte obrázek pro nahrání nebo klikněte pro jeho výběr." + }, + "references": { + "all_missing": "Nelze nalézt referencovaná data.", + "many_missing": "Minimálně jedna z referencí už není dostupná.", + "single_missing": "Reference se nezdá být nadále dostupná." + }, + "password": { + "toggle_visible": "Skrýt heslo", + "toggle_hidden": "Ukázat heslo" + } + }, + "message": { + "about": "O Navidrome", + "are_you_sure": "Jste si jistý?", + "bulk_delete_content": "Jste si jistý, že chcete smazat %{name}? |||| Jste si jistý, že chcete smazat těchto %{smart_count} položek?", + "bulk_delete_title": "Smazat %{name} |||| Smazat %{smart_count} %{name} položek", + "delete_content": "Jste si jistý, že chcete smazat tuto položku?", + "delete_title": "Smazat %{name} #%{id}", + "details": "Detaily", + "error": "Objevila se chyba klienta a váš požadavek nemohl být splněn.", + "invalid_form": "Formulář není platný. Prosím překontrolujte ho.", + "loading": "Stránka se načítá, prosím vyčkejte", + "no": "Ne", + "not_found": "Napsali jste špatnou adresu URL, nebo jste následovali špatný odkaz.", + "yes": "Ano", + "unsaved_changes": "Některé vaše změny nebyly uloženy. Jste si jisti, že je chcete ignorovat?" + }, + "navigation": { + "no_results": "Žádné výsledky nebyly nalezeny", + "no_more_results": "Stránka číslo %{page} je mimo rozsah. Zkuste předchozí.", + "page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah", + "page_out_from_end": "Nelze jít za poslední stranu", + "page_out_from_begin": "Nelze jít před první stranu", + "page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}", + "page_rows_per_page": "Položek na stránce:", + "next": "Další", + "prev": "Předchozí", + "skip_nav": "Přeskočit na obsah" + }, + "notification": { + "updated": "Prvek aktualizován |||| %{smart_count} prvků aktualizováno", + "created": "Prvek vytvořen", + "deleted": "Prvek smazán |||| %{smart_count} prvků smazáno", + "bad_item": "Nesprávný prvek", + "item_doesnt_exist": "Prvek neexistuje", + "http_error": "Chyba komunikace serveru", + "data_provider_error": "Chyba dataProvideru. Detaily najdete v konzoli.", + "i18n_error": "Nelze načíst překlady pro vybraný jazyk", + "canceled": "Akce zrušena", + "logged_out": "Vaše relace skončila, prosím připojte se znovu.", + "new_version": "Je dostupná nová verze! Prosím obnovte toto okno." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Sloupce na displej", + "layout": "Rozložení", + "grid": "Mřížka", + "table": "Tabulka" + } + }, + "message": { + "note": "POZNÁMKA", + "transcodingDisabled": "Měnění nastavení překódování je ve webovém prostředí vypnuto kvůli bezpečnosti. Pokud by jste chtěli změnit (upravit nebo přidat) možnosti překódování, restartujte server s možností %{config}.", + "transcodingEnabled": "Navidrome právě běží s možností %{config}, umožňující spouštění systémových příkazů z nastavení překódování pomocí webového rozhraní. Doporučujeme ji vypnout kvůli bezpečnosti a použít ji pouze pokud upravujete nastavení překódování.", + "songsAddedToPlaylist": "1 skladba přidána na seznam skladeb |||| %{smart_count} skladeb přidáno na seznam skladeb", + "noPlaylistsAvailable": "Žádné nejsou dostupné", + "delete_user_title": "Odstranit uživatele '%{name}'", + "delete_user_content": "Jste si jisti že chcete odstranit tohoto uživatele a všechny jejich data (zahrujicí seznamy skladeb a nastavení)?", + "notifications_blocked": "Zablokovali jste si oznámení pro tuto stránku v nastavení vašeho prohlížeče", + "notifications_not_available": "Tento prohlížeč nepodporuje oznámení na ploše nebo nepřistupujete k Navidrome přes https", + "lastfmLinkSuccess": "Last.fm úspěšně připojeno a scrobblování zapnuto", + "lastfmLinkFailure": "Last.fm nemohlo být připojeno", + "lastfmUnlinkSuccess": "Last.fm odpojeno a scrobblování vypnuto", + "lastfmUnlinkFailure": "Last.fm nemohlo být odpojeno", + "openIn": { + "lastfm": "Otevřít na Last.fm", + "musicbrainz": "Otevřít na MusicBrainz" + }, + "lastfmLink": "Číst dále...", + "listenBrainzLinkSuccess": "ListenBrainz úspěšně připojeno a scrobblování zapnuto jako uživatel: %{user}", + "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" + }, + "menu": { + "library": "Knihovna", + "settings": "Nastavení", + "version": "Verze", + "theme": "Téma", + "personal": { + "name": "Osobní", + "options": { + "theme": "Téma", + "language": "Jazyk", + "defaultView": "Výchozí stránka", + "desktop_notifications": "Oznámení na ploše", + "lastfmScrobbling": "Scrobblovat na Last.fm", + "listenBrainzScrobbling": "Scrobblovat na ListenBrainz", + "replaygain": "Mód ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Vypnuto", + "album": "Použít Album Gain", + "track": "Použít Track Gain" + } + } + }, + "albumList": "Alba", + "about": "O Navidrome", + "playlists": "Seznamy skladeb", + "sharedPlaylists": "Sdílené seznamy skladeb" + }, + "player": { + "playListsText": "Fronta", + "openText": "Otevřít", + "closeText": "Zavřít", + "notContentText": "Žádné skladby", + "clickToPlayText": "Klikněte pro přehrání", + "clickToPauseText": "Klikněte pro pozastavní", + "nextTrackText": "Další skladba", + "previousTrackText": "Předchozí skladba", + "reloadText": "Znovu načíst", + "volumeText": "Hlasitost", + "toggleLyricText": "Přepnout text", + "toggleMiniModeText": "Zmenšit", + "destroyText": "Zničit", + "downloadText": "Stáhnout", + "removeAudioListsText": "Vymazat seznam", + "clickToDeleteText": "Klikněte pro odstratění %{name}", + "emptyLyricText": "Bez textu", + "playModeText": { + "order": "Popořadě", + "orderLoop": "Opakovat", + "singleLoop": "Opakovat jednou", + "shufflePlay": "Zamíchat" + } + }, + "about": { + "links": { + "homepage": "Domovská stránka", + "source": "Zdrojový kód", + "featureRequests": "Požadavky o funkce" + } + }, + "activity": { + "title": "Aktivita", + "totalScanned": "Naskenované složky", + "quickScan": "Rychlý sken", + "fullScan": "Úplný sken", + "serverUptime": "Doba od spuštění", + "serverDown": "OFFLINE" + }, + "help": { + "title": "Klávesové zkratky Navidrome", + "hotkeys": { + "show_help": "Ukázat tuto nápovědu", + "toggle_menu": "Přepnout postranní menu", + "toggle_play": "Přehrát / Pozastavit", + "prev_song": "Předchozí skladba", + "next_song": "Následující skladba", + "vol_up": "Zvýšit hlasitost", + "vol_down": "Snížit hlasitost", + "toggle_love": "Přidat tuto skladbu do oblíbených", + "current_song": "Přejít na aktuální skladbu" + } + } +} \ No newline at end of file diff --git a/resources/i18n/da.json b/resources/i18n/da.json new file mode 100644 index 0000000..550c884 --- /dev/null +++ b/resources/i18n/da.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "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" + } + }, + "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" + } + } + }, + "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" + } + }, + "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" + }, + "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" + }, + "lastfmNotConfigured": "Last.fm API-nøglen er ikke konfigureret" + } + }, + "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" + } + }, + "tabs": { + "about": "Om", + "config": "Konfiguration" + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..22e2fab --- /dev/null +++ b/resources/i18n/de.json @@ -0,0 +1,634 @@ +{ + "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": "CD 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" + }, + "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" + } + }, + "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" + }, + "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" + }, + "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" + }, + "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" + } + }, + "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" + } + }, + "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" + }, + "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" + } + } + }, + "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" + } + }, + "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": "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" + }, + "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" + }, + "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" + }, + "lastfmNotConfigured": "Last.fm API-Key ist nicht konfiguriert" + } + }, + "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" + } + }, + "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" + } + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..4dd58e9 --- /dev/null +++ b/resources/i18n/el.json @@ -0,0 +1,634 @@ +{ + "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": "Βιβλιοθήκη" + }, + "actions": { + "addToQueue": "Αναπαραγωγη Μετα", + "playNow": "Αναπαραγωγή Τώρα", + "addToPlaylist": "Προσθήκη στη λίστα αναπαραγωγής", + "shuffleAll": "Ανακατεμα ολων", + "download": "Ληψη", + "playNext": "Επόμενη Αναπαραγωγή", + "info": "Εμφάνιση Πληροφοριών", + "showInPlaylist": "Εμφάνιση στη λίστα αναπαραγωγής" + } + }, + "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": "Token", + "lastAccessAt": "Τελευταία Πρόσβαση", + "libraries": "Βιβλιοθήκες" + }, + "helperTexts": { + "name": "Αλλαγές στο όνομα σας θα εφαρμοστούν στην επόμενη σύνδεση", + "libraries": "Επιλέξτε συγκεκριμένες βιβλιοθήκες για αυτόν τον χρήστη, ή αφήστε την κενή για να χρησιμοποιήσετε την προεπιλεγμένη βιβλιοθήκη" + }, + "notifications": { + "created": "Ο χρήστης δημιουργήθηκε", + "updated": "Ο χρήστης ενημερώθηκε", + "deleted": "Ο χρήστης διαγράφηκε" + }, + "message": { + "listenBrainzToken": "Εισάγετε το token του χρήστη σας στο ListenBrainz.", + "clickHereForToken": "Κάντε κλικ εδώ για να αποκτήσετε το token σας", + "selectAllLibraries": "Επιλογή όλων των βιβλιοθηκών", + "adminAutoLibraries": "Οι χρήστες διαχειριστές έχουν αυτόματα πρόσβαση σε όλες τις βιβλιοθήκες" + }, + "validation": { + "librariesRequired": "Πρέπει να επιλεγεί τουλάχιστον μία βιβλιοθήκη για χρήστες που δεν είναι διαχειριστές" + } + }, + "player": { + "name": "Συσκευή Αναπαραγωγής |||| Συσκευές Αναπαραγωγής", + "fields": { + "name": "Όνομα", + "transcodingId": "Διακωδικοποίηση", + "maxBitRate": "Μεγ. Ρυθμός Bit", + "client": "Πελάτης", + "userName": "Ονομα Χρηστη", + "lastSeen": "Τελευταια προβολη στις", + "reportRealPath": "Αναφορά Πραγματικής Διαδρομής", + "scrobbleEnabled": "Αποστολή Scrobbles σε εξωτερικές συσκευές" + } + }, + "transcoding": { + "name": "Διακωδικοποίηση |||| Διακωδικοποιήσεις", + "fields": { + "name": "Όνομα", + "targetFormat": "Μορφη Προορισμου", + "defaultBitRate": "Προκαθορισμένος Ρυθμός Bit", + "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": "Ρεύμα 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": "Δεν έχουν αντιστοιχιστεί βιβλιοθήκες σε αυτόν τον χρήστη" + } + } + }, + "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": "Δεν βρέθηκαν κορυφαία τραγούδια" + }, + "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": "Κανένα" + } + }, + "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": "Χρόνος που πέρασε", + "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 new file mode 100644 index 0000000..7a13c47 --- /dev/null +++ b/resources/i18n/eo.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "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" + } + }, + "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" + } + } + }, + "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" + } + }, + "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" + }, + "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" + }, + "lastfmNotConfigured": "API-ŝlosilo de Last.fm ne agordita" + } + }, + "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" + } + }, + "tabs": { + "about": "Pri", + "config": "Agordo" + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..c620d77 --- /dev/null +++ b/resources/i18n/es.json @@ -0,0 +1,634 @@ +{ + "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", + "missing": "Faltante", + "libraryName": "Biblioteca" + }, + "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" + } + }, + "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", + "artist": "Artista", + "composer": "Compositor", + "conductor": "Director de orquesta", + "lyricist": "Letrista", + "arranger": "Arreglista", + "producer": "Productor", + "director": "Director", + "engineer": "Ingeniero de sonido", + "mixer": "Mezclador", + "remixer": "Remixer", + "djmixer": "DJ Mixer", + "performer": "Intérprete", + "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 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", + "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", + "fields": { + "username": "Nombre de usuario", + "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": "Faltante", + "fields": { + "path": "Ruta", + "size": "Tamaño", + "updatedAt": "Actualizado el", + "libraryName": "Biblioteca" + }, + "actions": { + "remove": "Eliminar", + "remove_all": "Eliminar todo" + }, + "notifications": { + "removed": "Eliminado" + }, + "empty": "No hay archivos perdidos" + }, + "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": "Valor por defecto para los 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" + } + } + }, + "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" + } + }, + "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 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": "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 elemento faltante", + "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 perdidos", + "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" + }, + "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": "Ninguno", + "album": "Álbum", + "track": "Pista" + }, + "lastfmNotConfigured": "La clave API de Last.fm no está configurada" + } + }, + "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" + } + }, + "tabs": { + "about": "Acerca de", + "config": "Configuración" + }, + "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" + } + }, + "activity": { + "title": "Actividad", + "totalScanned": "Total de carpetas escaneadas", + "quickScan": "Escaneo rápido", + "fullScan": "Escaneo completo", + "serverUptime": "Uptime del servidor", + "serverDown": "OFFLINE", + "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/eu.json b/resources/i18n/eu.json new file mode 100644 index 0000000..0c968e2 --- /dev/null +++ b/resources/i18n/eu.json @@ -0,0 +1,630 @@ +{ + "languageName": "Euskara", + "resources": { + "song": { + "name": "Abestia |||| Abestiak", + "fields": { + "albumArtist": "Albumaren artista", + "duration": "Iraupena", + "trackNumber": "#", + "playCount": "Erreprodukzioak", + "title": "Titulua", + "artist": "Artista", + "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", + "channels": "Kanalak", + "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": "Traola gehiago", + "mappedTags": "Esleitutako traolak", + "rawTags": "Traola 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" + } + }, + "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 |||| Argitaratzeak", + "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 egilea |||| 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", + "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", + "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" + } + } + }, + "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": "AJaregin 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": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira", + "created": "Elementua sortu da", + "deleted": "Elementu bat 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. Berrikusi 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": { + "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", + "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-n", + "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)", + "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 azkarra", + "fullScan": "Arakatze sakona", + "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 new file mode 100644 index 0000000..86504af --- /dev/null +++ b/resources/i18n/fa.json @@ -0,0 +1,460 @@ +{ + "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": "", + "originalDate": "", + "releaseDate": "", + "releases": "", + "released": "" + }, + "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": "کدگذاری", + "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": "" + } + } + }, + "ra": { + "auth": { + "welcome1": "از اینکه نویدروم را نصب کردید متشکریم!", + "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": "باید با یک قالب خاص (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": "", + "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": "نمیتوان به قبل از صفحه 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": "نویدروم در حال حاضر با %{config}, اجرا می شود و امکان اجرای دستورات سیستم از تنظیمات کدگذاری با استفاده از رابط وب را ممکن می سازد. توصیه می کنیم آن را به دلایل امنیتی غیرفعال کنید و فقط هنگام پیکربندی گزینه های کدگذاری آن را فعال کنید.", + "songsAddedToPlaylist": "1 آهنگ به لیست پخش اضافه شد |||| %{smart_count} آهنگ به لیست پخش اضافه شد", + "noPlaylistsAvailable": "موجود نیست", + "delete_user_title": "حذف کاربر '%{name}'", + "delete_user_content": "آیا مطمئن هستید که می خواهید این کاربر و همه داده های او (از جمله لیست پخش و تنظیمات برگزیده اش) را حذف کنید؟", + "notifications_blocked": "شما اعلان های این سایت را در تنظیمات مرورگر خود مسدود کرده اید", + "notifications_not_available": "این مرورگر از اعلان های دسکتاپ پشتیبانی نمی کند یا از طریق https به نوید روم دسترسی ندارید", + "lastfmLinkSuccess": "last.fm با موفقیت مرتبط شد و scrobbling فعال شد", + "lastfmLinkFailure": "last.fm پیوند داده نشد", + "lastfmUnlinkSuccess": "last.fm لغو پیوند شده و scrobbling غیرفعال شده است", + "lastfmUnlinkFailure": "پیوند last.fm را نمی توان لغو کرد", + "openIn": { + "lastfm": "بازکردن در Last.fm", + "musicbrainz": "بازکردن در موزیک براینز" + }, + "lastfmLink": "بیشتر بخوانید...", + "listenBrainzLinkSuccess": "", + "listenBrainzLinkFailure": "", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" + }, + "menu": { + "library": "کتابخانه", + "settings": "تنظیمات", + "version": "نسخه", + "theme": "تم", + "personal": { + "name": "شخصی", + "options": { + "theme": "تم", + "language": "زبان", + "defaultView": "نمای پیش فرض", + "desktop_notifications": "اعلان های دسکتاپ", + "lastfmScrobbling": "اسکروبل به Last.fm", + "listenBrainzScrobbling": "", + "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": "درهم" + } + }, + "about": { + "links": { + "homepage": "صفحه نخست", + "source": "کد منبع", + "featureRequests": "درخواست های ویژگی" + } + }, + "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": "" + } + } +} diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json new file mode 100644 index 0000000..fc27933 --- /dev/null +++ b/resources/i18n/fi.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "actions": { + "addToQueue": "Lisää jonoon", + "playNow": "Soita nyt", + "addToPlaylist": "Lisää soittolistaan", + "shuffleAll": "Sekoita kaikki", + "download": "Lataa", + "playNext": "Soita seuraavaksi", + "info": "Info", + "showInPlaylist": "Näytä soittolistassa" + } + }, + "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" + }, + "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" + }, + "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" + }, + "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" + } + }, + "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", + "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" + } + } + }, + "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" + } + }, + "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" + }, + "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" + }, + "lastfmNotConfigured": "Last.fm API-avainta ei ole määritetty" + } + }, + "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" + } + }, + "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" + } + }, + "tabs": { + "about": "Tietoja", + "config": "Kokoonpano" + }, + "config": { + "configName": "Konfiguraation nimi", + "environmentVariable": "Ympäristömuuttuja", + "currentValue": "Nykyinen arvo", + "configurationFile": "Konfiguraatiotiedosto", + "exportToml": "Vie konfiguraatio (TOML)", + "exportSuccess": "Konfiguraatio viety leikepöydälle TOML-muodossa", + "exportFailed": "Konfiguraation kopiointi epäonnistui", + "devFlagsHeader": "Kehitysliput (voivat muuttua/poistua)", + "devFlagsComment": "Nämä ovat kokeellisia asetuksia ja ne voidaan poistaa tulevissa versioissa" + } + }, + "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 new file mode 100644 index 0000000..070e639 --- /dev/null +++ b/resources/i18n/fr.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "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" + } + }, + "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" + } + } + }, + "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" + } + }, + "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é" + }, + "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" + }, + "lastfmNotConfigured": "La clef API de Last.fm n'est pas configurée" + } + }, + "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" + } + }, + "tabs": { + "about": "À propos", + "config": "Paramètres" + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..a5f7ce0 --- /dev/null +++ b/resources/i18n/gl.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "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" + } + }, + "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" + }, + "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" + }, + "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" + }, + "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" + } + }, + "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", + "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" + } + } + }, + "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 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": { + "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" + }, + "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" + }, + "lastfmNotConfigured": "Clave da API Last.fm non configurada" + } + }, + "albumList": "Álbums", + "about": "Acerca de", + "playlists": "Listas de reprodución", + "sharedPlaylists": "Listas compartidas", + "librarySelector": { + "allLibraries": "Todas as bibliotecas (%{count})", + "multipleLibraries": "%{selected} de %{total} Bibliotecas", + "selectLibraries": "Seleccionar Bibliotecas", + "none": "Ningunha" + } + }, + "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", + "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" + } + }, + "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 new file mode 100644 index 0000000..5b9ece5 --- /dev/null +++ b/resources/i18n/hi.json @@ -0,0 +1,630 @@ +{ + "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 new file mode 100644 index 0000000..cbdd571 --- /dev/null +++ b/resources/i18n/hu.json @@ -0,0 +1,631 @@ +{ + "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ó", + "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", + "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" + } + }, + "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" + } + } + }, + "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": "Nincs %{name} még.", + "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": { + "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", + "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)", + "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 new file mode 100644 index 0000000..38ee2ff --- /dev/null +++ b/resources/i18n/id.json @@ -0,0 +1,628 @@ +{ + "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" + }, + "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" + } + }, + "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" + }, + "notifications": { + "created": "Pustaka berhasil dibuat", + "updated": "Pustaka berhasil dibuat", + "deleted": "Berhasil menghapus pustaka", + "scanStarted": "Memindai pustaka dimulai", + "scanCompleted": "Memindai pustaka selesai" + }, + "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" + } + } + }, + "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" + }, + "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" + } + }, + "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" + }, + "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 new file mode 100644 index 0000000..11fadb4 --- /dev/null +++ b/resources/i18n/it.json @@ -0,0 +1,460 @@ +{ + "languageName": "Italiano", + "resources": { + "song": { + "name": "Traccia |||| Tracce", + "fields": { + "albumArtist": "Artista Album", + "duration": "Durata", + "trackNumber": "#", + "playCount": "Riproduzioni", + "title": "Titolo", + "artist": "Artista", + "album": "Album", + "path": "Percorso", + "genre": "Genere", + "compilation": "Compilation", + "year": "Anno", + "size": "Dimensioni", + "updatedAt": "Ultimo aggiornamento", + "bitRate": "Bitrate", + "discSubtitle": "Sottotitoli disco", + "starred": "Preferita", + "comment": "Commento", + "rating": "Valutazione", + "quality": "Qualità", + "bpm": "BPM", + "playDate": "Ultima riproduzione", + "channels": "Canali", + "createdAt": "" + }, + "actions": { + "addToQueue": "Aggiungi alla coda", + "playNow": "Riproduci adesso", + "addToPlaylist": "Aggiungi alla playlist", + "shuffleAll": "Riproduci casualmente", + "download": "Scarica", + "playNext": "Riproduci come successivo", + "info": "Informazioni" + } + }, + "album": { + "name": "Album |||| Album", + "fields": { + "albumArtist": "Artista Album", + "artist": "Artista", + "duration": "Durata", + "songCount": "Tracce", + "playCount": "Riproduzioni", + "name": "Nome", + "genre": "Genere", + "compilation": "Compilation", + "year": "Anno", + "updatedAt": "Ultimo aggiornamento", + "comment": "Commento", + "rating": "Valutazione", + "createdAt": "Data di creazione", + "size": "Dimensione", + "originalDate": "", + "releaseDate": "Data di pubblicazione", + "releases": "Pubblicazione |||| Pubblicazioni", + "released": "Pubblicato" + }, + "actions": { + "playAll": "Riproduci", + "playNext": "Riproduci come successivo", + "addToQueue": "Aggiungi alla coda", + "shuffle": "Riproduci casualmente", + "addToPlaylist": "Aggiungi alla Playlist", + "download": "Scarica", + "info": "Informazioni", + "share": "Condividi" + }, + "lists": { + "all": "Tutti", + "random": "Casuali", + "recentlyAdded": "Aggiunti di Recente", + "recentlyPlayed": "Riprodotti di Recente", + "mostPlayed": "I Più Riprodotti", + "starred": "Preferiti", + "topRated": "Più votati" + } + }, + "artist": { + "name": "Artista |||| Artisti", + "fields": { + "name": "Nome", + "albumCount": "Album", + "songCount": "Numero tracce", + "playCount": "Riproduzioni", + "rating": "Valutazione", + "genre": "Genere", + "size": "Dimensione" + } + }, + "user": { + "name": "Utente |||| Utenti", + "fields": { + "userName": "Nome utente", + "isAdmin": "Amministratore", + "lastLoginAt": "Ultimo accesso", + "updatedAt": "Ultimo aggiornamento", + "name": "Nome", + "password": "Password", + "createdAt": "Creato a", + "changePassword": "Cambiare la password?", + "currentPassword": "Password Attuale", + "newPassword": "Nuova Password", + "token": "Token" + }, + "helperTexts": { + "name": "Le modifiche effettuate al tuo nome verrano mostrate al prossimo accesso" + }, + "notifications": { + "created": "Utente creato", + "updated": "Utente aggiornato", + "deleted": "Utente eliminato" + }, + "message": { + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", + "clickHereForToken": "Clicca qui per ottenere il tuo token" + } + }, + "player": { + "name": "Client |||| Client", + "fields": { + "name": "Nome", + "transcodingId": "Transcodifica", + "maxBitRate": "Bitrate massimo", + "client": "Applicazione", + "userName": "Nome utente", + "lastSeen": "Ultimo accesso", + "reportRealPath": "Mostra percorso reale", + "scrobbleEnabled": "" + } + }, + "transcoding": { + "name": "Transcodifica |||| Transcodifiche", + "fields": { + "name": "Nome", + "targetFormat": "Formato", + "defaultBitRate": "Bitrate predefinito", + "command": "Comando" + } + }, + "playlist": { + "name": "Playlist |||| Playlist", + "fields": { + "name": "Nome", + "duration": "Durata", + "ownerName": "Creatore", + "public": "Pubblica", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data creazione", + "songCount": "Tracce", + "comment": "Commento", + "sync": "Importazione automatica", + "path": "Importa da" + }, + "actions": { + "selectPlaylist": "Aggiungi tracce alla playlist:", + "addNewPlaylist": "Aggiungi \"%{name}\"", + "export": "Esporta", + "makePublic": "Rendi Pubblica", + "makePrivate": "Rendi Privata" + }, + "message": { + "duplicate_song": "Aggiungere i duplicati", + "song_exist": "Stanno essendo aggiunti dei duplicati nella playlist. Vuoi aggiungerli o saltarli?" + } + }, + "radio": { + "name": "Radio |||| Radio", + "fields": { + "name": "Nome", + "streamUrl": "", + "homePageUrl": "", + "updatedAt": "", + "createdAt": "" + }, + "actions": { + "playNow": "" + } + }, + "share": { + "name": "", + "fields": { + "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 amministratore", + "confirmPassword": "Conferma la password", + "buttonCreateAdmin": "Crea amministratore", + "auth_check_error": "Per favore accedi per continuare", + "user_menu": "Profile", + "username": "Nome utente", + "password": "Password", + "sign_in": "Accedi", + "sign_in_error": "Autenticazione fallita, per favore riprova", + "logout": "Disconnetti" + }, + "validation": { + "invalidChars": "Per favore usa solo lettere e numeri", + "passwordDoesNotMatch": "Le password non coincidono", + "required": "Campo obbligatorio", + "minLength": "Deve essere lungo almeno %{min} caratteri", + "maxLength": "Deve essere lungo al massimo %{max} caratteri", + "minValue": "Deve essere almeno %{min}", + "maxValue": "Deve essere al massimo %{max}", + "number": "Deve essere un numero", + "email": "Deve essere un indirizzo email valido", + "oneOf": "Deve essere uno di: %{options}", + "regex": "Deve rispettare il formato (espressione regolare): %{pattern}", + "unique": "Deve essere unico", + "url": "" + }, + "action": { + "add_filter": "Aggiungi un filtro", + "add": "Aggiungi", + "back": "Indietro", + "bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati", + "cancel": "Annulla", + "clear_input_value": "Cancella", + "clone": "Duplica", + "confirm": "Conferma", + "create": "Crea", + "delete": "Rimuovi", + "edit": "Modifica", + "export": "Esporta", + "list": "Elenco", + "refresh": "Aggiorna", + "remove_filter": "Rimuovi questo filtro", + "remove": "Remove", + "save": "Salva", + "search": "Cerca", + "show": "Mostra", + "sort": "Ordina", + "undo": "Annulla", + "expand": "Espandi", + "close": "Chiudi", + "open_menu": "Apri menù", + "close_menu": "Chiudi menù", + "unselect": "Deseleziona", + "skip": "Saltare i duplicati", + "bulk_actions_mobile": "", + "share": "", + "download": "" + }, + "boolean": { + "true": "Si", + "false": "No" + }, + "page": { + "create": "Aggiungi %{name}", + "dashboard": "Pannello di controllo", + "edit": "%{name} #%{id}", + "error": "Qualcosa è andato storto", + "list": "%{name}", + "loading": "Caricamento in corso", + "not_found": "Non trovato", + "show": "%{name} #%{id}", + "empty": "Nessun %{name} per adesso.", + "invite": "Vuoi invitare un amico?" + }, + "input": { + "file": { + "upload_several": "Trascina i file da caricare, oppure clicca per selezionarli.", + "upload_single": "Trascina il file da caricare, oppure clicca per selezionarlo." + }, + "image": { + "upload_several": "Trascina le immagini da caricare, oppure clicca per selezionarle.", + "upload_single": "Trascina l'immagine da caricare, oppure clicca per selezionarla." + }, + "references": { + "all_missing": "Impossibile trovare i riferimenti associati.", + "many_missing": "Almeno uno dei riferimenti associati sembra non essere più disponibile.", + "single_missing": "Il riferimento associato sembra non essere più disponibile." + }, + "password": { + "toggle_visible": "Nascondi password", + "toggle_hidden": "Mostra password" + } + }, + "message": { + "about": "Informazioni", + "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": "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.", + "invalid_form": "Il modulo non è valido. Per favore controlla la presenza di errori.", + "loading": "La pagina si sta caricando, solo un momento per favore", + "no": "No", + "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", + "yes": "Si", + "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?" + }, + "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_from_begin": "Non è possibile andare prima della prima pagina", + "page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}", + "page_rows_per_page": "Righe per pagina:", + "next": "Successivo", + "prev": "Precedente", + "skip_nav": "Passa al contenuto" + }, + "notification": { + "updated": "Elemento aggiornato |||| %{smart_count} elementi aggiornati", + "created": "Elemento creato", + "deleted": "Elemento rimosso |||| %{smart_count} elementi rimossi", + "bad_item": "Elemento errato", + "item_doesnt_exist": "Elemento inesistente", + "http_error": "Errore di comunicazione con il server", + "data_provider_error": "Errore del dataProvider. Controlla la console per i dettagli.", + "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" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Colonne da mostrare", + "layout": "Disposizione", + "grid": "Griglia", + "table": "Tabella" + } + }, + "message": { + "note": "Note", + "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l’interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l’opzione %{config}.", + "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso l’interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", + "songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist", + "noPlaylistsAvailable": "Nessuna playlist", + "delete_user_title": "Rimuovi utente '%{name}'", + "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?", + "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", + "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", + "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", + "lastfmLinkFailure": "Non è stato possible collegare Last.fm", + "lastfmUnlinkSuccess": "Lo scrobbling è stato disabilitato e Last.fm è stato disconnesso", + "lastfmUnlinkFailure": "Non è stato possibile scollegare Last.fm", + "openIn": { + "lastfm": "Apri in Last.fm", + "musicbrainz": "Apri in MusicBrainz" + }, + "lastfmLink": "Per saperne di più...", + "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente %{user}", + "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "", + "listenBrainzUnlinkFailure": "", + "downloadOriginalFormat": "", + "shareOriginalFormat": "", + "shareDialogTitle": "", + "shareBatchDialogTitle": "", + "shareSuccess": "", + "shareFailure": "", + "downloadDialogTitle": "", + "shareCopyToClipboard": "" + }, + "menu": { + "library": "Libreria", + "settings": "Impostazioni", + "version": "Versione", + "theme": "Tema", + "personal": { + "name": "Personale", + "options": { + "theme": "Tema", + "language": "Lingua", + "defaultView": "Vista Predefinita", + "desktop_notifications": "Notifiche desktop", + "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", + "listenBrainzScrobbling": "", + "replaygain": "", + "preAmp": "", + "gain": { + "none": "", + "album": "", + "track": "" + } + } + }, + "albumList": "Album", + "about": "Info", + "playlists": "Playlist", + "sharedPlaylists": "Playlist Condivise" + }, + "player": { + "playListsText": "Coda", + "openText": "Apri", + "closeText": "Chiudi", + "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 testo", + "toggleMiniModeText": "Minimizza", + "destroyText": "Distruggi", + "downloadText": "Scarica", + "removeAudioListsText": "Cancella coda", + "clickToDeleteText": "Clicca per rimuovere %{name}", + "emptyLyricText": "Nessun testo", + "playModeText": { + "order": "In ordine", + "orderLoop": "Ripeti", + "singleLoop": "Ripeti una volta", + "shufflePlay": "Casuale" + } + }, + "about": { + "links": { + "homepage": "Sito web", + "source": "Codice sorgente", + "featureRequests": "Richieste" + } + }, + "activity": { + "title": "Attività", + "totalScanned": "Cartelle scansionate", + "quickScan": "Scansione veloce", + "fullScan": "Scansione completa", + "serverUptime": "Periodo di attività", + "serverDown": "OFFLINE" + }, + "help": { + "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", + "vol_up": "Alza il Volume", + "vol_down": "Abbassa il Volume", + "toggle_love": "Aggiungi questa traccia ai preferiti", + "current_song": "" + } + } +} diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json new file mode 100644 index 0000000..29975b9 --- /dev/null +++ b/resources/i18n/ja.json @@ -0,0 +1,634 @@ +{ + "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": "追加日", + "grouping": "グループ分け", + "mood": "ムード", + "participants": "追加参加者", + "tags": "追加タグ", + "mappedTags": "マッピング済みタグ", + "rawTags": "未処理タグ", + "bitDepth": "ビット深度", + "sampleRate": "サンプリングレート", + "missing": "不明", + "libraryName": "ライブラリ" + }, + "actions": { + "addToQueue": "最後に再生", + "playNow": "すぐに再生", + "addToPlaylist": "プレイリストに追加", + "shuffleAll": "全曲シャッフル", + "download": "ダウンロード", + "playNext": "次に再生", + "info": "詳細", + "showInPlaylist": "含まれるプレイリスト" + } + }, + "album": { + "name": "アルバム", + "fields": { + "albumArtist": "アルバムアーティスト", + "artist": "アーティスト", + "duration": "長さ", + "songCount": "曲", + "playCount": "再生数", + "name": "名前", + "genre": "ジャンル", + "compilation": "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 ミキサー", + "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": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります" + } + }, + "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": "非公開にする", + "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": "このユーザーに割り当てられているライブラリはありません" + } + } + }, + "ra": { + "auth": { + "welcome1": "Navidromeをインストールいただきありがとうございます!", + "welcome2": "管理ユーザーを作成して始めましょう", + "confirmPassword": "パスワードの確認", + "buttonCreateAdmin": "管理者の作成", + "auth_check_error": "認証に失敗しました。再度ログインしてください", + "user_menu": "プロフィール", + "username": "ユーザー名", + "password": "パスワード", + "sign_in": "ログイン", + "sign_in_error": "認証に失敗しました。入力を確認してください", + "logout": "ログアウト", + "insightsCollectionNote": "Navidromeでは、プロジェクトの改善に役立てるため、匿名の利用データを収集しています。詳しくは [here] をクリックしてください。" + }, + "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": { + "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": "クリップボードへコピー: Ctrl+C, Enter", + "remove_missing_title": "欠落ファイルを削除", + "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。", + "remove_all_missing_title": "全ての欠落ファイルを削除", + "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。", + "noSimilarSongsFound": "類似の曲が見つかりませんでした", + "noTopSongsFound": "トップソングが見つかりません" + }, + "menu": { + "library": "ライブラリ", + "settings": "設定", + "version": "バージョン", + "theme": "テーマ", + "personal": { + "name": "個人設定", + "options": { + "theme": "テーマ", + "language": "言語", + "defaultView": "デフォルト画面", + "desktop_notifications": "デスクトップ通知", + "lastfmScrobbling": "Last.fmへscrobbleする", + "listenBrainzScrobbling": "ListenBrainzへscrobble", + "replaygain": "ReplayGainモード", + "preAmp": "プリアンプ", + "gain": { + "none": "無効", + "album": "アルバムゲインを使う", + "track": "トラックゲインを使う" + }, + "lastfmNotConfigured": "Last.fmのAPIキーが設定されていません" + } + }, + "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": "待機中" + } + }, + "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": "経過時間", + "selectiveScan": "選択的スキャン" + }, + "help": { + "title": "ホットキー", + "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/ko.json b/resources/i18n/ko.json new file mode 100644 index 0000000..6b81e02 --- /dev/null +++ b/resources/i18n/ko.json @@ -0,0 +1,629 @@ +{ + "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 new file mode 100644 index 0000000..059d243 --- /dev/null +++ b/resources/i18n/nl.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "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" + } + }, + "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" + }, + "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" + }, + "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" + }, + "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" + } + }, + "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": "Afspeelduur", + "ownerName": "Eigenaar", + "public": "Publiek", + "updatedAt": "Laatst bijgewerkt 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", + "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" + } + } + }, + "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" + } + }, + "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" + }, + "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" + }, + "lastfmNotConfigured": "Last.fm API-sleutel is niet geconfigureerd" + } + }, + "albumList": "Albums", + "about": "Over", + "playlists": "Afspeellijsten", + "sharedPlaylists": "Gedeelde afspeellijsten", + "librarySelector": { + "allLibraries": "Alle bibliotheken (%{count})", + "multipleLibraries": "%{selected} van %{total} bibliotheken", + "selectLibraries": "Selecteer bibliotheken", + "none": "Geen" + } + }, + "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" + } + }, + "tabs": { + "about": "Over", + "config": "Configuratie" + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..3b75bab --- /dev/null +++ b/resources/i18n/no.json @@ -0,0 +1,634 @@ +{ + "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 new file mode 100644 index 0000000..a9d6db8 --- /dev/null +++ b/resources/i18n/pl.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "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" + } + }, + "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" + }, + "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" + }, + "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" + }, + "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" + } + }, + "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ą", + "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" + } + } + }, + "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" + } + }, + "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" + }, + "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" + }, + "lastfmNotConfigured": "Klucz-API Last.fm jest nieskonfigurowany" + } + }, + "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" + } + }, + "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", + "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" + } + }, + "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 new file mode 100644 index 0000000..3f095b0 --- /dev/null +++ b/resources/i18n/pt-br.json @@ -0,0 +1,634 @@ +{ + "languageName": "Português (Brasil)", + "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", + "libraryName": "Biblioteca", + "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" + }, + "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" + } + }, + "album": { + "name": "Álbum |||| Álbuns", + "fields": { + "albumArtist": "Artista", + "artist": "Artista", + "duration": "Duração", + "songCount": "Músicas", + "playCount": "Execuções", + "name": "Nome", + "libraryName": "Biblioteca", + "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" + }, + "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", + "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": { + "topSongs": "Mais tocadas", + "shuffle": "Aleatório", + "radio": "Rádio" + } + }, + "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" + }, + "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" + }, + "notifications": { + "created": "Novo usuário criado", + "updated": "Usuário atualizado com sucesso", + "deleted": "Usuário deletado com sucesso" + }, + "validation": { + "librariesRequired": "Pelo menos uma biblioteca deve ser selecionada para usuários não-administradores" + }, + "message": { + "listenBrainzToken": "Entre seu token do ListenBrainz", + "clickHereForToken": "Clique aqui para obter seu token", + "selectAllLibraries": "Selecionar todas as bibliotecas", + "adminAutoLibraries": "Usuários administradores têm acesso automático a todas as bibliotecas" + } + }, + "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", + "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", + "libraryName": "Biblioteca", + "updatedAt": "Desaparecido em" + }, + "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" + }, + "sections": { + "basic": "Informações Básicas", + "statistics": "Estatísticas" + }, + "actions": { + "scan": "Scanear Biblioteca", + "quickScan": "Scan Rápido", + "fullScan": "Scan Completo", + "manageUsers": "Gerenciar Acesso do Usuário", + "viewDetails": "Ver Detalhes" + }, + "notifications": { + "created": "Biblioteca criada com sucesso", + "updated": "Biblioteca atualizada com sucesso", + "deleted": "Biblioteca excluída com sucesso", + "scanStarted": "Scan da biblioteca iniciada", + "quickScanStarted": "Scan rápido iniciado", + "fullScanStarted": "Scan completo iniciado", + "scanError": "Erro ao iniciar o scan. Verifique os logs", + "scanCompleted": "Scan da biblioteca concluída" + }, + "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" + } + } + }, + "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", + "noSimilarSongsFound": "Nenhuma música semelhante encontrada", + "noTopSongsFound": "Nenhuma música mais tocada encontrada", + "noPlaylistsAvailable": "Nenhuma playlist", + "delete_user_title": "Excluir usuário '%{name}'", + "delete_user_content": "Você tem certeza que deseja excluir o usuário e todos os seus dados (incluindo suas playlists e preferências)?", + "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." + }, + "menu": { + "library": "Biblioteca", + "librarySelector": { + "allLibraries": "Todas as Bibliotecas (%{count})", + "multipleLibraries": "%{selected} de %{total} Bibliotecas", + "selectLibraries": "Selecionar Bibliotecas", + "none": "Nenhuma" + }, + "settings": "Configurações", + "version": "Versão", + "theme": "Tema", + "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" + }, + "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" + } + }, + "activity": { + "title": "Atividade", + "totalScanned": "Total de pastas scaneadas", + "quickScan": "Rápido", + "fullScan": "Completo", + "selectiveScan": "Seletivo", + "serverUptime": "Uptime do servidor", + "serverDown": "DESCONECTADO", + "scanType": "Último Scan", + "status": "Erro", + "elapsedTime": "Duração" + }, + "nowPlaying": { + "title": "Tocando agora", + "empty": "Nada tocando", + "minutesAgo": "%{smart_count} minuto atrás |||| %{smart_count} minutos atrás" + }, + "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" + } + } +} diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json new file mode 100644 index 0000000..2d7ffd2 --- /dev/null +++ b/resources/i18n/ru.json @@ -0,0 +1,634 @@ +{ + "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": "Дата добавления", + "grouping": "Группирование", + "mood": "Настроение", + "participants": "Дополнительные участники", + "tags": "Дополнительные теги", + "mappedTags": "Сопоставленные теги", + "rawTags": "Исходные теги", + "bitDepth": "Битовая глубина (Bit)", + "sampleRate": "Частота дискретизации (Hz)", + "missing": "Поле отсутствует", + "libraryName": "Библиотека" + }, + "actions": { + "addToQueue": "В очередь", + "playNow": "Играть", + "addToPlaylist": "Добавить в плейлист", + "shuffleAll": "Перемешать", + "download": "Скачать", + "playNext": "Следующий", + "info": "Информация", + "showInPlaylist": "Показать в плейлисте" + } + }, + "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": "Отправлять скробблы во внешние службы" + } + }, + "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": "Нет библиотек, назначенных этому пользователю" + } + } + }, + "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": "Должно быть в формате (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": { + "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" + }, + "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", + "remove_missing_title": "Удалить отсутствующие файлы?", + "remove_missing_content": "Вы уверены, что хотите удалить выбранные отсутствующие файлы из базы данных? Это навсегда удалит все ссылки на них, включая данные о прослушиваниях и рейтингах.", + "remove_all_missing_title": "Удалите все отсутствующие файлы", + "remove_all_missing_content": "Вы уверены, что хотите удалить все отсутствующие файлы из базы данных? Это навсегда удалит все упоминания о них, включая количество игр и рейтинг.", + "noSimilarSongsFound": "Похожих треков не найдено", + "noTopSongsFound": "Лучших треков не найдено" + }, + "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": "Использовать усиление трека" + }, + "lastfmNotConfigured": "API-ключ Last.fm не настроен" + } + }, + "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": "Ожидание" + } + }, + "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": "Прошедшее время", + "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/sl.json b/resources/i18n/sl.json new file mode 100644 index 0000000..80bd8e4 --- /dev/null +++ b/resources/i18n/sl.json @@ -0,0 +1,628 @@ +{ + "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" + }, + "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" + } + }, + "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" + }, + "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" + }, + "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" + } + } + }, + "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" + } + }, + "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" + }, + "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" + }, + "lastfmNotConfigured": "Last.fm API ključ ni konfiguriran" + } + }, + "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" + } + }, + "tabs": { + "about": "O nas", + "config": "Konfiguracija" + }, + "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" + } + }, + "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" + }, + "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 new file mode 100644 index 0000000..1cf7e39 --- /dev/null +++ b/resources/i18n/sr.json @@ -0,0 +1,517 @@ +{ + "languageName": "српски", + "resources": { + "song": { + "name": "Песма |||| Песме", + "fields": { + "album": "Албум", + "albumArtist": "Уметник албума", + "artist": "Уметник", + "bitDepth": "Битова", + "bitRate": "Битски проток", + "bpm": "BPM", + "channels": "Канала", + "comment": "Коментар", + "compilation": "Компилација", + "createdAt": "Датум додавања", + "discSubtitle": "Поднаслов диска", + "duration": "Трајање", + "genre": "Жанр", + "grouping": "Груписање", + "mappedTags": "Мапиране ознаке", + "mood": "Расположење", + "participants": "Додатни учесници", + "path": "Путања фајла", + "playCount": "Пуштано", + "playDate": "Последње пуштано", + "quality": "Квалитет", + "rating": "Рејтинг", + "rawTags": "Сирове ознаке", + "size": "Величина фајла", + "starred": "Омиљено", + "tags": "Додатне ознаке", + "title": "Наслов", + "trackNumber": "#", + "updatedAt": "Ажурирано", + "year": "Година" + }, + "actions": { + "addToPlaylist": "Додај у плејлисту", + "addToQueue": "Пусти касније", + "download": "Преузми", + "info": "Прикажи инфо", + "playNext": "Пусти наредно", + "playNow": "Пусти одмах", + "shuffleAll": "Измешај све" + } + }, + "album": { + "name": "Албум |||| Албуми", + "fields": { + "albumArtist": "Уметник албума", + "artist": "Уметник", + "catalogNum": "Каталошки број", + "comment": "Коментар", + "compilation": "Компилација", + "createdAt": "Датум додавања", + "date": "Датум снимања", + "duration": "Трајање", + "genre": "Жанр", + "grouping": "Груписање", + "media": "Медијум", + "mood": "Расположење", + "name": "Назив", + "originalDate": "Оригинално", + "playCount": "Пуштано", + "rating": "Рејтинг", + "recordLabel": "Издавачка кућа", + "releaseDate": "Објављено", + "releaseType": "Тип", + "released": "Објављено", + "releases": "Издање|||| Издања", + "size": "Величина", + "songCount": "Песме", + "updatedAt": "Ажурирано", + "year": "Година" + }, + "actions": { + "addToPlaylist": "Додај у плејлисту", + "addToQueue": "Пусти касније", + "download": "Преузми", + "info": "Прикажи инфо", + "playAll": "Пусти", + "playNext": "Пусти наредно", + "share": "Дели", + "shuffle": "Измешај" + }, + "lists": { + "all": "Све", + "mostPlayed": "Најчешће пуштано", + "random": "Насумично", + "recentlyAdded": "Додато недавно", + "recentlyPlayed": "Пуштано недавно", + "starred": "Омиљено", + "topRated": "Најбоље рангирано" + } + }, + "artist": { + "name": "Уметник |||| Уметници", + "fields": { + "albumCount": "Број албума", + "genre": "Жанр", + "name": "Назив", + "playCount": "Пуштано", + "rating": "Рејтинг", + "role": "Улога", + "size": "Величина", + "songCount": "Број песама" + }, + "roles": { + "albumartist": "Уметник албума |||| Уметници албума", + "arranger": "Аранжер |||| Аранжери", + "artist": "Уметник |||| Уметници", + "composer": "Композитор |||| Композитори", + "conductor": "Диригент |||| Диригенти", + "director": "Режисер |||| Режисери", + "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", + "engineer": "Инжењер |||| Инжењери", + "lyricist": "Текстописац |||| Текстописци", + "mixer": "Миксер |||| Миксери", + "performer": "Извођач |||| Извођачи", + "producer": "Продуцент |||| Продуценти", + "remixer": "Ремиксер |||| Ремиксери" + } + }, + "user": { + "name": "Корисник |||| Корисници", + "fields": { + "changePassword": "Измени лозинку?", + "createdAt": "Креирана", + "currentPassword": "Текућа лозинка", + "isAdmin": "Да ли је Админ", + "lastAccessAt": "Последњи приступ", + "lastLoginAt": "Последња пријава", + "name": "Назив", + "newPassword": "Нова лозинка", + "password": "Лозинка", + "token": "Жетон", + "updatedAt": "Ажурирано", + "userName": "Корисничко име" + }, + "helperTexts": { + "name": "Измене вашег имена ће постати видљиве након следеће пријаве" + }, + "notifications": { + "created": "Корисник креиран", + "deleted": "Корисник обрисан", + "updated": "Корисник ажуриран" + }, + "message": { + "clickHereForToken": "Кликните овде да преузмете свој жетон", + "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон." + } + }, + "player": { + "name": "Плејер |||| Плејери", + "fields": { + "client": "Клијент", + "lastSeen": "Последњи пут виђен", + "maxBitRate": "Макс. битски проток", + "name": "Назив", + "reportRealPath": "Пријављуј реалну путању", + "scrobbleEnabled": "Шаљи скроблове на спољне сервисе", + "transcodingId": "Транскодирање", + "userName": "Корисничко име" + } + }, + "transcoding": { + "name": "Транскодирање |||| Транскодирања", + "fields": { + "command": "Команда", + "defaultBitRate": "Подразумевани битски проток", + "name": "Назив", + "targetFormat": "Циљни формат" + } + }, + "playlist": { + "name": "Плејлиста |||| Плејлисте", + "fields": { + "comment": "Коментар", + "createdAt": "Креирана", + "duration": "Трајање", + "name": "Назив", + "ownerName": "Власник", + "path": "Увоз из", + "public": "Јавна", + "songCount": "Песме", + "sync": "Ауто-увоз", + "updatedAt": "Ажурирано" + }, + "actions": { + "addNewPlaylist": "Креирај „%{name}”", + "export": "Извези", + "makePrivate": "Учини приватном", + "makePublic": "Учини јавном", + "selectPlaylist": "Изабери плејлисту" + }, + "message": { + "duplicate_song": "Додај дуплиране песме", + "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?" + } + }, + "radio": { + "name": "Радио |||| Радији", + "fields": { + "createdAt": "Креирана", + "homePageUrl": "URL почетне странице", + "name": "Назив", + "streamUrl": "URL тока", + "updatedAt": "Ажурирано" + }, + "actions": { + "playNow": "Пусти одмах" + } + }, + "share": { + "name": "Дељење |||| Дељења", + "fields": { + "contents": "Садржај", + "createdAt": "Креирано", + "description": "Опис", + "downloadable": "Допушта се преузимање?", + "expiresAt": "Истиче", + "format": "Формат", + "lastVisitedAt": "Последњи пут посећено", + "maxBitRate": "Макс. битски проток", + "updatedAt": "Ажурирано", + "url": "URL", + "username": "Поделио", + "visitCount": "Број посета" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "Фајл који недостаје|||| Фајлови који недостају", + "empty": "Нема фајлова који недостају", + "fields": { + "path": "Путања", + "size": "Величина", + "updatedAt": "Нестао дана" + }, + "actions": { + "remove": "Уклони" + }, + "notifications": { + "removed": "Фајл који недостаје, или више њих, је уклоњен" + } + } + }, + "ra": { + "auth": { + "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", + "buttonCreateAdmin": "Креирај админа", + "confirmPassword": "Потврдите лозинку", + "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите", + "logout": "Одјави се", + "password": "Лозинка", + "sign_in": "Пријави се", + "sign_in_error": "Потврда идентитета није успела, покушајте поново", + "user_menu": "Профил", + "username": "Корисничко име", + "welcome1": "Хвала што сте инсталирали Navidrome!", + "welcome2": "За почетак, креирајте админ корисника" + }, + "validation": { + "email": "Мора да буде исправна и-мејл адреса", + "invalidChars": "Молимо вас да користите само слова и цифре", + "maxLength": "Мора да буде %{max} карактера или мање", + "maxValue": "Мора да буде %{max} или мање", + "minLength": "Мора да буде барем %{min} карактера", + "minValue": "Мора да буде барем %{min}", + "number": "Мора да буде број", + "oneOf": "Мора да буде једно од: %{options}", + "passwordDoesNotMatch": "Лозинка се не подудара", + "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", + "required": "Неопходно", + "unique": "Мора да буде јединствено", + "url": "Мора да буде исправна URL адреса" + }, + "action": { + "add": "Додај", + "add_filter": "Додај филтер", + "back": "Иди назад", + "bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "cancel": "Откажи", + "clear_input_value": "Обриши вредност", + "clone": "Клонирај", + "close": "Затвори", + "close_menu": "Затвори мени", + "confirm": "Потврди", + "create": "Креирај", + "delete": "Обриши", + "download": "Преузми", + "edit": "Уреди", + "expand": "Развиј", + "export": "Извези", + "list": "Листа", + "open_menu": "Отвори мени", + "refresh": "Освежи", + "remove": "Уклони", + "remove_filter": "Уклони овај филтер", + "save": "Сачувај", + "search": "Тражи", + "share": "Дели", + "show": "Прикажи", + "skip": "Прескочи", + "sort": "Сортирај", + "undo": "Поништи", + "unselect": "Уклони избор" + }, + "boolean": { + "false": "Не", + "true": "Да" + }, + "page": { + "create": "Креирај %{name}", + "dashboard": "Контролна табла", + "edit": "%{name} #%{id}", + "empty": "Још увек нема %{name}.", + "error": "Нешто је пошло наопако", + "invite": "Желите ли да се дода?", + "list": "%{name}", + "loading": "Учитава се", + "not_found": "Није пронађено", + "show": "%{name} #%{id}" + }, + "input": { + "file": { + "upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.", + "upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете." + }, + "image": { + "upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.", + "upload_single": "Упустите слику да се отпреми, или кликните да је изаберете." + }, + "password": { + "toggle_hidden": "Прикажи лозинку", + "toggle_visible": "Сакриј лозинку" + }, + "references": { + "all_missing": "Не могу да се нађу подаци референци.", + "many_missing": "Изгледа да барем једна од придружених референци више није доступна.", + "single_missing": "Изгледа да придружена референца више није доступна." + } + }, + "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 адресу, или сте следили неисправан линк.", + "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?", + "yes": "Да" + }, + "navigation": { + "next": "Наредна", + "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", + "no_results": "Није пронађен ниједан резултат", + "page_out_from_begin": "Не може да се иде испред странице 1", + "page_out_from_end": "Не може да се иде након последње странице", + "page_out_of_boundaries": "Број странице %{page} је ван опсега", + "page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}", + "page_rows_per_page": "Ставки по страници:", + "prev": "Претход", + "skip_nav": "Прескочи на садржај" + }, + "notification": { + "bad_item": "Неисправни елемент", + "canceled": "Акција је отказана", + "created": "Елемент је креиран", + "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", + "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", + "http_error": "Грешка у комуникацији са сервером", + "i18n_error": "Не могу да се учитају преводи за наведени језик", + "item_doesnt_exist": "Елемент не постоји", + "logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.", + "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.", + "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Колоне за приказ", + "grid": "Мрежа", + "layout": "Распоред", + "table": "Табела" + } + }, + "message": { + "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", + "delete_user_title": "Брисање корисника ’%{name}’", + "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", + "downloadOriginalFormat": "Преузми у оригиналном формату", + "lastfmLink": "Прочитај још...", + "lastfmLinkFailure": "Last.fm није могао да се повеже", + "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", + "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", + "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", + "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", + "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", + "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", + "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", + "noPlaylistsAvailable": "Није доступна ниједна", + "note": "НАПОМЕНА", + "notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења", + "notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола", + "openIn": { + "lastfm": "Отвори у Last.fm", + "musicbrainz": "Отвори у MusicBrainz" + }, + "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", + "remove_missing_title": "Уклони фајлове који недостају", + "shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}", + "shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер", + "shareDialogTitle": "Подели %{resource} ’%{name}’", + "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", + "shareOriginalFormat": "Подели у оригиналном формату", + "shareSuccess": "URL је копиран у клипборд: %{url}", + "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", + "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", + "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања." + }, + "menu": { + "about": "О", + "albumList": "Албуми", + "library": "Библиотека", + "personal": { + "name": "Лична", + "options": { + "defaultView": "Подразумевани поглед", + "desktop_notifications": "Десктоп обавештења", + "gain": { + "album": "Користи Album појачање", + "none": "Искључено", + "track": "Користи Track појачање" + }, + "language": "Језик", + "lastfmNotConfigured": "Није подешен Last.fm API-кључ", + "lastfmScrobbling": "Скроблуј на Last.fm", + "listenBrainzScrobbling": "Скроблуј на ListenBrainz", + "preAmp": "ReplayGain претпојачање (dB)", + "replaygain": "ReplayGain режим", + "theme": "Тема" + } + }, + "playlists": "Плејлисте", + "settings": "Подешавања", + "sharedPlaylists": "Дељене плејлисте", + "theme": "Тема", + "version": "Верзија" + }, + "player": { + "clickToDeleteText": "Кликните да обришете %{name}", + "clickToPauseText": "Кликни за паузирање", + "clickToPlayText": "Кликни за пуштање", + "closeText": "Затвори", + "destroyText": "Уништи", + "downloadText": "Преузми", + "emptyLyricText": "Нема стихова", + "nextTrackText": "Наредна нумера", + "notContentText": "Нема музике", + "openText": "Отвори", + "playListsText": "Ред за пуштање", + "playModeText": { + "order": "По редоследу", + "orderLoop": "Понови", + "shufflePlay": "Измешај", + "singleLoop": "Понови једну" + }, + "previousTrackText": "Претходна нумера", + "reloadText": "Поново учитај", + "removeAudioListsText": "Обриши аудио листе", + "toggleLyricText": "Укљ./Искљ. стихове", + "toggleMiniModeText": "Умањи", + "volumeText": "Јачина" + }, + "about": { + "links": { + "featureRequests": "Захтеви за функцијама", + "homepage": "Почетна страница", + "insights": { + "disabled": "Искључено", + "waiting": "Чека се" + }, + "lastInsightsCollection": "Последња колекција увида", + "source": "Изворни кôд" + } + }, + "activity": { + "fullScan": "Комплетно скенирање", + "quickScan": "Брзо скенирање", + "serverDown": "ВАН МРЕЖЕ", + "serverUptime": "Сервер се извршава", + "title": "Активност", + "totalScanned": "Укупан број скенираних фолдера" + }, + "help": { + "title": "Navidrome пречице", + "hotkeys": { + "current_song": "Иди на текућу песму", + "next_song": "Наредна песма", + "prev_song": "Претходна песма", + "show_help": "Прикажи ову помоћ", + "toggle_love": "Додај ову нумеру у омиљене", + "toggle_menu": "Укљ./Искљ. бочну траку менија", + "toggle_play": "Пусти / Паузирај", + "vol_down": "Утишај", + "vol_up": "Појачај" + } + } +} diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json new file mode 100644 index 0000000..30bf89e --- /dev/null +++ b/resources/i18n/sv.json @@ -0,0 +1,634 @@ +{ + "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": "Sökväg", + "genre": "Genre", + "compilation": "Samling", + "year": "År", + "size": "Filstorlek", + "updatedAt": "Uppdaterad", + "bitRate": "Bitrate", + "discSubtitle": "Underrubrik", + "starred": "Favorit", + "comment": "Kommentar", + "rating": "Betyg", + "quality": "Kvalitet", + "bpm": "BPM", + "playDate": "Senast spelad", + "channels": "Channels", + "createdAt": "Skapad", + "grouping": "Gruppering", + "mood": "Stämning", + "participants": "Ytterligare medverkande", + "tags": "Ytterligare taggar", + "mappedTags": "Mappade taggar", + "rawTags": "Omodifierade taggar", + "bitDepth": "Bitdjup", + "sampleRate": "Samplingsfrekvens", + "missing": "Saknade", + "libraryName": "Bibliotek" + }, + "actions": { + "addToQueue": "Lägg till i kön", + "playNow": "Spela nu", + "addToPlaylist": "Lägg till i spellista", + "shuffleAll": "Shuffle", + "download": "Ladda ner", + "playNext": "Spela nästa", + "info": "Mer information", + "showInPlaylist": "Visa i spellista" + } + }, + "album": { + "name": "Album |||| Album", + "fields": { + "albumArtist": "Albumartist", + "artist": "Artist", + "duration": "Längd", + "songCount": "Antal låtar", + "playCount": "Spelningar", + "name": "Namn", + "genre": "Genre", + "compilation": "Samling", + "year": "År", + "updatedAt": "Uppdaterad", + "comment": "Kommentar", + "rating": "Betyg", + "createdAt": "Skapad", + "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": "Saknade", + "libraryName": "Bibliotek" + }, + "actions": { + "playAll": "Spela", + "playNext": "Spela härnäst", + "addToQueue": "Lägg till i kön", + "shuffle": "Shuffle", + "addToPlaylist": "Lägg till i spellista", + "download": "Ladda ner", + "info": "Mer information", + "share": "Dela" + }, + "lists": { + "all": "Alla", + "random": "Blanda", + "recentlyAdded": "Senast tillagda", + "recentlyPlayed": "Senast spelade", + "mostPlayed": "Mest spelade", + "starred": "Favoriter", + "topRated": "Bästa betyg" + } + }, + "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": "Saknade" + }, + "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": "Albumartister eller Artist |||| Albumartister eller Artister" + }, + "actions": { + "shuffle": "Shuffle", + "radio": "Radio", + "topSongs": "Topplåtar" + } + }, + "user": { + "name": "Användare |||| Användare", + "fields": { + "userName": "Användarnamn", + "isAdmin": "Är admin", + "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": "Token", + "lastAccessAt": "Senaste åtkomst", + "libraries": "Bibliotek" + }, + "helperTexts": { + "name": "Ändringar av ditt namn syns först vid nästa inloggning", + "libraries": "Välj ett bibliotek för denna användare eller lämna blankt för standardbibliotek" + }, + "notifications": { + "created": "Användare skapad", + "updated": "Användare uppdaterad", + "deleted": "Användare borttagen" + }, + "message": { + "listenBrainzToken": "Ange din ListenBrainz användar-token.", + "clickHereForToken": "Klicka här för att hämta din token", + "selectAllLibraries": "Välj alla bibliotek", + "adminAutoLibraries": "Administratörer har automatiskt tillgång till alla bibliotek" + }, + "validation": { + "librariesRequired": "Minst ett bibliotek måste väljas för icke-administratörer" + } + }, + "player": { + "name": "Spelare |||| Spelare", + "fields": { + "name": "Namn", + "transcodingId": "Omkodning", + "maxBitRate": "Max. bitrate", + "client": "Klient", + "userName": "Användarnamn", + "lastSeen": "Senast sedd", + "reportRealPath": "Visa hela sökvägen", + "scrobbleEnabled": "Scrobbla till extern tjänst" + } + }, + "transcoding": { + "name": "Omkodning |||| Omkodningar", + "fields": { + "name": "Namn", + "targetFormat": "Målformat", + "defaultBitRate": "Standardbitrate", + "command": "Kommando" + } + }, + "playlist": { + "name": "Spellista |||| Spellistor", + "fields": { + "name": "Namn", + "duration": "Längd", + "ownerName": "Ägare", + "public": "Offentlig", + "updatedAt": "Uppdaterad", + "createdAt": "Skapad", + "songCount": "Låtar", + "comment": "Kommentar", + "sync": "Auto-import", + "path": "Importera från" + }, + "actions": { + "selectPlaylist": "Välj en spellista:", + "addNewPlaylist": "Skapa \"%{name}\"", + "export": "Exportera", + "makePublic": "Gör offentlig", + "makePrivate": "Gör privat", + "saveQueue": "Spara kö till spellista", + "searchOrCreate": "Sök spellista eller skapa ny...", + "pressEnterToCreate": "Tryck Enter för att skapa ny spellista", + "removeFromSelection": "Ta bort från urval" + }, + "message": { + "duplicate_song": "Lägg till dubletter", + "song_exist": "Vissa låtar finns redan i spellistan. Vill du lägga till dubbletterna eller hoppa över dem?", + "noPlaylistsFound": "Hittade inga spellistor", + "noPlaylists": "Inga spellistor tillgängliga" + } + }, + "radio": { + "name": "Radio |||| Radior", + "fields": { + "name": "Namn", + "streamUrl": "Stream-URL", + "homePageUrl": "Hemside-URL", + "updatedAt": "Uppdaterad", + "createdAt": "Skapad" + }, + "actions": { + "playNow": "Spela nu" + } + }, + "share": { + "name": "Dela |||| 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": "Max. bitrate", + "updatedAt": "Uppdaterad", + "createdAt": "Skapad", + "downloadable": "Tillåt nedladdning?" + } + }, + "missing": { + "name": "Saknad fil |||| Saknade filer", + "fields": { + "path": "Sökväg", + "size": "Storlek", + "updatedAt": "Försvann", + "libraryName": "Bibliotek" + }, + "actions": { + "remove": "Radera", + "remove_all": "Radera alla" + }, + "notifications": { + "removed": "Saknade fil(er) borttagna" + }, + "empty": "Inga saknade filer" + }, + "library": { + "name": "Bibliotek |||| Bibliotek", + "fields": { + "name": "Namn", + "path": "Sökväg", + "remotePath": "Ta bort sökväg", + "lastScanAt": "Senaste scan", + "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": "Scanna bibliotek", + "manageUsers": "Hantera användaråtkomst", + "viewDetails": "Se detaljer", + "quickScan": "Snabbscan", + "fullScan": "Komplett scan" + }, + "notifications": { + "created": "Biblioteket har skapats", + "updated": "Biblioteket har uppdaterats", + "deleted": "Biblioteket har raderats", + "scanStarted": "Biblioteksscan startad", + "scanCompleted": "Biblioteksscan avslutad", + "quickScanStarted": "Snabbscan startad", + "fullScanStarted": "Komplett scan startad", + "scanError": "Fel vid start av scan. Se loggarna" + }, + "validation": { + "nameRequired": "Biblioteksnamn krävs", + "pathRequired": "Bibliotekssökväg krävs", + "pathNotDirectory": "Bibliotekssökvägen måste vara en katalog", + "pathNotFound": "Bibliotekssökväg hittades inte", + "pathNotAccessible": "Bibliotekssökväg inte tillgänglig", + "pathInvalid": "Ogiltig bibliotekssökväg" + }, + "messages": { + "deleteConfirm": "Är du säker på att du vill ta bort detta bibliotek? Detta raderar all förbunden data och användartillgång.", + "scanInProgress": "Scanning pågår...", + "noLibrariesAssigned": "Inga bibliotek har tilldelats den här användaren" + } + } + }, + "ra": { + "auth": { + "welcome1": "Tack för att du installerade Navidrome!", + "welcome2": "Skapa först ett admin-konto", + "confirmPassword": "Bekräfta lösenord", + "buttonCreateAdmin": "Skapa admin-konto", + "auth_check_error": "Logga in för att fortsätta", + "user_menu": "Profil", + "username": "Användarnamn", + "password": "Lösenord", + "sign_in": "Logga in", + "sign_in_error": "Felaktig inloggning, försök igen", + "logout": "Logga ut", + "insightsCollectionNote": "Navidrome samlar anonym användardata för att\nhjälpa projektet att bli bättre. Klicka [här]\nför att läsa mer och avaktivera om du vill" + }, + "validation": { + "invalidChars": "Använd enbart bokstäver och siffror", + "passwordDoesNotMatch": "Lösenordet matchar inte", + "required": "Krävs", + "minLength": "Måste ha minst %{min} tecken", + "maxLength": "Får maximalt ha %{max} tecken", + "minValue": "Måste vara minst %{min}", + "maxValue": "Får maximalt vara %{max}", + "number": "Måste vara ett nummer", + "email": "Måste vara en giltig e-postadress", + "oneOf": "Måste vara en av: %{options}", + "regex": "Måste matcha ett specifikt format (regexp): %{pattern}", + "unique": "Måste vara unik", + "url": "Måste vara en giltig URL" + }, + "action": { + "add_filter": "Lägg till filter", + "add": "Lägg till", + "back": "Tillbaka", + "bulk_actions": "1 objekt vald |||| %{smart_count} objekt valda", + "cancel": "Avbryt", + "clear_input_value": "Rensa", + "clone": "Klona", + "confirm": "Bekräfta", + "create": "Skapa", + "delete": "Ta bort", + "edit": "Redigera", + "export": "Exportera", + "list": "Lista", + "refresh": "Uppdatera", + "remove_filter": "Ta bort filter", + "remove": "Radera", + "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": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Ett fel uppstod", + "list": "%{name}", + "loading": "Laddar", + "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 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": "Hittade ingen referensdata.", + "many_missing": "Minst en av de associerade referenserna verkar inte längre vara tillgänglig.", + "single_missing": "Associerade referenser verkar inte längre vara tillgängliga." + }, + "password": { + "toggle_visible": "Dölj password", + "toggle_hidden": "Visa password" + } + }, + "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 detta innehåll?", + "delete_title": "Ta bort %{name} #%{id}", + "details": "Detaljer", + "error": "Ett klientfel uppstod och begäran kunde inte slutföras.", + "invalid_form": "Formuläret är ogiltigt. Kontrollera eventuella fel", + "loading": "Sidan läses in, var god vänta", + "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} 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": "Antal per sida:", + "next": "Nästa", + "prev": "Föregående", + "skip_nav": "Hoppa till innehåll" + }, + "notification": { + "updated": "Element uppdaterat |||| %{smart_count} element uppdaterade", + "created": "Element skapat", + "deleted": "Element borttaget |||| %{smart_count} element borttagna", + "bad_item": "Felaktigt element", + "item_doesnt_exist": "Element finns inte", + "http_error": "Kommunikationsfel med servern", + "data_provider_error": "Fel i dataProvider. Kontrollera din konsol för mer information.", + "i18n_error": "Kunde inte läsa in översättningen av det valda språket", + "canceled": "Åtgärden avbröts", + "logged_out": "Sessionen har avslutats, anslut på nytt.", + "new_version": "Det finns en ny version! Uppdatera detta fönster." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Kolumner att visa", + "layout": "Layout", + "grid": "Rutnät", + "table": "Tabell" + } + }, + "message": { + "note": "OBSERVERA", + "transcodingDisabled": "Inställning för kodning via webbgränssnittet är av säkerhetsskäl ej aktiverat. Starta om servern med alternativet %{config} markerat om du vill göra ändringar (redigera eller lägga till).", + "transcodingEnabled": "Navidrome körs för närvarande med %{config}, vilket gör att systemkommandon kan köras från webbplattformen. Du rekommenderas av säkerhetsskäl att du stänger av den och bara slår på den när du ställer in omkodning.", + "songsAddedToPlaylist": "La till en låt i spellistan |||| La till %{smart_count} låtar i spellistan", + "noPlaylistsAvailable": "Ingen tillgänglig", + "delete_user_title": "Ta bort användare '%{name}'", + "delete_user_content": "Är du säker på att du vill ta bort denna användare (inklusive spellistor och inställningar)?", + "notifications_blocked": "Du har blockerat meddelanden från denna sajt in din webbläsares inställningar", + "notifications_not_available": "Denna webbläsare stödjer inte skrivbordsmeddelanden eller du använder inte Navidrome via https", + "lastfmLinkSuccess": "Last.fm är länkat och scrobbling är aktivt", + "lastfmLinkFailure": "Last.fm kunde inte länkas", + "lastfmUnlinkSuccess": "Last.fm är inte längre länkat och scrobbling är deaktiverat", + "lastfmUnlinkFailure": "Last.fm kunde inte avlänkas", + "openIn": { + "lastfm": "Öppna i Last.fm", + "musicbrainz": "Öppna i MusicBrainz" + }, + "lastfmLink": "Läs mer...", + "listenBrainzLinkSuccess": "ListenBrainz är länkat och scrobbling är aktivt som användare: %{user}", + "listenBrainzLinkFailure": "ListenBrainz kunde inte länkas: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz är inte längre länkat och scrobbling är deaktiverat", + "listenBrainzUnlinkFailure": "ListenBrainz kunde inte avlänkas", + "downloadOriginalFormat": "Ladda ner i originalformat", + "shareOriginalFormat": "Dela i originalformat", + "shareDialogTitle": "Dela %{resource} '%{name}'", + "shareBatchDialogTitle": "Dela en %{resource} |||| Dela %{smart_count} %{resource}", + "shareSuccess": "URL kopierades till urklipp: %{url}", + "shareFailure": "Fel vid kopiering av URL %{url} till urklipp", + "downloadDialogTitle": "Ladda ner %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopiera till urklipp: Ctrl+C, Enter", + "remove_missing_title": "Ta bort saknade filer", + "remove_missing_content": "Är du säker på att du vill ta bort de valda saknade filerna från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "remove_all_missing_title": "Ta bort alla saknade filer", + "remove_all_missing_content": "Är du säker på att du vill ta bort alla saknade filer från databasen? Detta kommer permanent radera alla referenser till dem, inklusive antal spelningar och betyg.", + "noSimilarSongsFound": "Hittade inga liknande låtar", + "noTopSongsFound": "Hittade inga topplåtar" + }, + "menu": { + "library": "Bibliotek", + "settings": "Inställningar", + "version": "Version", + "theme": "Tema", + "personal": { + "name": "Personligt", + "options": { + "theme": "Tema", + "language": "Språk", + "defaultView": "Standardvy", + "desktop_notifications": "Skrivbordsmeddelanden", + "lastfmScrobbling": "Scrobbla till Last.fm", + "listenBrainzScrobbling": "Scrobbla till ListenBrainz", + "replaygain": "ReplayGain-läge", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Inaktiverad", + "album": "Använd gain för album", + "track": "Använd gain für låtar" + }, + "lastfmNotConfigured": "Last.fm API-nyckel är inte konfigurerad" + } + }, + "albumList": "Album", + "about": "Om", + "playlists": "Spellistor", + "sharedPlaylists": "Delade spellistor", + "librarySelector": { + "allLibraries": "Alla bibliotek (%{count})", + "multipleLibraries": "%{selected} av %{total} bibliotek", + "selectLibraries": "Valda bibliotek", + "none": "Inga" + } + }, + "player": { + "playListsText": "Spela kön", + "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": "Låttext av/på", + "toggleMiniModeText": "Minimera", + "destroyText": "Radera", + "downloadText": "Ladda ner", + "removeAudioListsText": "Ta bort audiolistor", + "clickToDeleteText": "Klicka för att ta bort %{name}", + "emptyLyricText": "Ingen låttext", + "playModeText": { + "order": "I ordningsföljd", + "orderLoop": "Upprepa", + "singleLoop": "Upprepa en", + "shufflePlay": "Shuffle" + } + }, + "about": { + "links": { + "homepage": "Hemsida", + "source": "Källkod", + "featureRequests": "Funktionalitetförfrågan", + "lastInsightsCollection": "Senaste Insights-kollektion", + "insights": { + "disabled": "Inaktiverad", + "waiting": "Väntar" + } + }, + "tabs": { + "about": "Om", + "config": "Inställningar" + }, + "config": { + "configName": "Inställningsnamn", + "environmentVariable": "Miljövariabel", + "currentValue": "Nuvarande värde", + "configurationFile": "Inställningsfil", + "exportToml": "Exportera inställningar (TOML)", + "exportSuccess": "Inställningarna kopierade till urklippet i TOML-format", + "exportFailed": "Kopiering av inställningarna misslyckades", + "devFlagsHeader": "Utvecklingsflaggor (kan ändras eller tas bort)", + "devFlagsComment": "Dessa inställningar är experimentella och kan tas bort i framtida versioner" + } + }, + "activity": { + "title": "Aktivitet", + "totalScanned": "Genomsökta mappar", + "quickScan": "Snabbscan", + "fullScan": "Komplett scan", + "serverUptime": "Serverdrifttid", + "serverDown": "OFFLINE", + "scanType": "Typ", + "status": "Fel vid scanning", + "elapsedTime": "Spelad tid", + "selectiveScan": "Urval" + }, + "help": { + "title": "Navidrome kortkommandon", + "hotkeys": { + "show_help": "Visa denna hjälp", + "toggle_menu": "Växla sidomeny", + "toggle_play": "Spela / pausa", + "prev_song": "Föregående låt", + "next_song": "Nästa låt", + "vol_up": "Volym upp", + "vol_down": "Volym ner", + "toggle_love": "Lägg till låt i favoriter", + "current_song": "Hoppa till nuvarande låt" + } + }, + "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 new file mode 100644 index 0000000..833a68a --- /dev/null +++ b/resources/i18n/th.json @@ -0,0 +1,634 @@ +{ + "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": "ห้องสมุด" + }, + "actions": { + "addToQueue": "เพิ่มในคิว", + "playNow": "เล่นทันที", + "addToPlaylist": "เพิ่มในเพลย์ลิสต์", + "shuffleAll": "สุ่มทั้งหมด", + "download": "ดาวน์โหลด", + "playNext": "เล่นถัดไป", + "info": "ดูรายละเอียด", + "showInPlaylist": "แสดงในเพลย์ลิสต์" + } + }, + "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": "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": "ห้องสมุด" + }, + "helperTexts": { + "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป", + "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น" + }, + "notifications": { + "created": "สร้างชื่อผู้ใช้", + "updated": "อัพเดตชื่อผู้ใช้", + "deleted": "ลบชื่อผู้ใช้" + }, + "message": { + "listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ", + "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ", + "selectAllLibraries": "เลือกห้องสมุดทั้งหมด", + "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ" + }, + "validation": { + "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล" + } + }, + "player": { + "name": "เพลย์เยอร์", + "fields": { + "name": "เล่นจาก", + "transcodingId": "แปลงไฟล์", + "maxBitRate": "บิตเรทสูงสุด", + "client": "ลูกข่าย", + "userName": "ชื่อผู้ใช้", + "lastSeen": "ใช้งานล่าสุดเมื่อ", + "reportRealPath": "รายงาน Real Path", + "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": "ทำเป็นส่วนตัว", + "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": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้" + } + } + }, + "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": "แสดงรหัสผ่าน" + } + }, + "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": "ไม่พบเพลงยอดนิยม" + }, + "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" + }, + "lastfmNotConfigured": "ยังไม่ได้ตั้งค่า Last.fm API-Key" + } + }, + "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": "รอ" + } + }, + "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": "เวลาที่ใช้", + "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 new file mode 100644 index 0000000..d1fdb2e --- /dev/null +++ b/resources/i18n/tr.json @@ -0,0 +1,634 @@ +{ + "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" + }, + "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" + } + }, + "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ı" + } + } + }, + "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" + } + }, + "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ş" + }, + "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" + }, + "lastfmNotConfigured": "Last.fm API Anahtarı yapılandırılmadı\n" + } + }, + "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" + } + }, + "tabs": { + "about": "Hakkında", + "config": "Yapılandırma" + }, + "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" + } + }, + "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 new file mode 100644 index 0000000..2c74c89 --- /dev/null +++ b/resources/i18n/uk.json @@ -0,0 +1,634 @@ +{ + "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": "Бібліотека" + }, + "actions": { + "addToQueue": "Прослухати пізніше", + "playNow": "Програвати зараз", + "addToPlaylist": "Додати у список відтворення", + "shuffleAll": "Перемішати", + "download": "Завантажити", + "playNext": "Наступна", + "info": "Отримати інформацію", + "showInPlaylist": "Показати у плейлісті" + } + }, + "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": "ID транскодування", + "maxBitRate": "Максимальний бітрейт", + "client": "Клієнт", + "userName": "Iм’я користувача", + "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": "Оновлено", + "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": "Немає бібліотек, призначених цьому користувачеві" + } + } + }, + "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": "Показати пароль" + } + }, + "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": "Не знайдено ТОП-треків" + }, + "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": "Використовувати підсилення для треку" + }, + "lastfmNotConfigured": "API-ключ Last.fm не налаштовано" + } + }, + "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": "Натисніть, щоб видалити", + "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": "Пройдений час", + "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 new file mode 100644 index 0000000..cde28c4 --- /dev/null +++ b/resources/i18n/zh-Hans.json @@ -0,0 +1,630 @@ +{ + "languageName": "简体中文", + "resources": { + "song": { + "name": "歌曲", + "fields": { + "albumArtist": "专辑歌手", + "duration": "时长", + "trackNumber": "歌曲序号", + "playCount": "播放次数", + "title": "曲名", + "artist": "歌手", + "album": "专辑", + "path": "文件路径", + "genre": "流派", + "libraryName": "媒体库", + "compilation": "合辑", + "year": "发行年份", + "size": "文件大小", + "updatedAt": "更新于", + "bitRate": "比特率", + "bitDepth": "比特深度", + "sampleRate": "采样率", + "channels": "声道", + "discSubtitle": "字幕", + "starred": "收藏", + "comment": "注释", + "rating": "评分", + "quality": "品质", + "bpm": "BPM", + "playDate": "最后一次播放", + "createdAt": "创建于", + "grouping": "分组", + "mood": "情绪", + "participants": "其他参与人员", + "tags": "附加标签", + "mappedTags": "映射标签", + "rawTags": "原始标签", + "missing": "缺失" + }, + "actions": { + "addToQueue": "加入播放列表", + "playNow": "立即播放", + "addToPlaylist": "加入歌单", + "showInPlaylist": "定位到播放列表", + "shuffleAll": "全部随机播放", + "download": "下载", + "playNext": "下一首播放", + "info": "查看信息" + } + }, + "album": { + "name": "专辑", + "fields": { + "albumArtist": "专辑歌手", + "artist": "歌手", + "duration": "时长", + "songCount": "歌曲数量", + "playCount": "播放次数", + "size": "文件大小", + "name": "名称", + "genre": "流派", + "libraryName": "媒体库", + "compilation": "合辑", + "year": "发行年份", + "date": "录制日期", + "originalDate": "原始日期", + "releaseDate": "发⾏日期", + "releases": "发⾏", + "released": "已发⾏", + "updatedAt": "更新于", + "comment": "注释", + "rating": "评分", + "createdAt": "创建于", + "recordLabel": "厂牌", + "catalogNum": "目录编号", + "releaseType": "发行类型", + "grouping": "分组", + "media": "媒体类型", + "mood": "情绪", + "missing": "缺失" + }, + "actions": { + "playAll": "立即播放", + "playNext": "下首播放", + "addToQueue": "加入播放列表", + "share": "分享", + "shuffle": "随机播放", + "addToPlaylist": "加入歌单", + "download": "下载", + "info": "查看信息" + }, + "lists": { + "all": "所有", + "random": "随机", + "recentlyAdded": "最近添加", + "recentlyPlayed": "最近播放", + "mostPlayed": "最多播放", + "starred": "收藏", + "topRated": "评分排行" + } + }, + "artist": { + "name": "艺术家", + "fields": { + "name": "名称", + "albumCount": "专辑数", + "songCount": "歌曲数", + "size": "文件大小", + "playCount": "播放次数", + "rating": "评分", + "genre": "流派", + "role": "参与角色", + "missing": "缺失" + }, + "roles": { + "albumartist": "专辑歌手", + "artist": "歌手", + "composer": "作曲", + "conductor": "指挥", + "lyricist": "作词", + "arranger": "编曲", + "producer": "制作人", + "director": "总监", + "engineer": "工程师", + "mixer": "混音师", + "remixer": "重混师", + "djmixer": "DJ混音师", + "performer": "演奏家", + "maincredit": "主要艺术家" + }, + "actions": { + "topSongs": "热门歌曲", + "shuffle": "随机播放", + "radio": "电台" + } + }, + "user": { + "name": "用户", + "fields": { + "userName": "用户名", + "isAdmin": "是否管理员", + "lastLoginAt": "上次登录", + "lastAccessAt": "上次访问", + "updatedAt": "更新于", + "name": "名称", + "password": "密码", + "createdAt": "创建于", + "changePassword": "修改密码?", + "currentPassword": "当前密码", + "newPassword": "新密码", + "token": "令牌", + "libraries": "媒体库" + }, + "helperTexts": { + "name": "名称的更改将在下次登录时生效", + "libraries": "为该用户选择指定媒体库,留空则使用默认媒体库" + }, + "notifications": { + "created": "用户已创建", + "updated": "用户已更新", + "deleted": "用户已删除" + }, + "validation": { + "librariesRequired": "普通用户必须至少选择一个媒体库" + }, + "message": { + "listenBrainzToken": "输入您的 ListenBrainz 用户令牌", + "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌", + "selectAllLibraries": "选择全部媒体库", + "adminAutoLibraries": "管理员默认可访问所有媒体库" + } + }, + "player": { + "name": "客户端", + "fields": { + "name": "名称", + "transcodingId": "转码编号", + "maxBitRate": "最大比特率", + "client": "客户端", + "userName": "用户名", + "lastSeen": "上次浏览", + "reportRealPath": "回报实际路径", + "scrobbleEnabled": "发送喜好记录到外部服务" + } + }, + "transcoding": { + "name": "转码", + "fields": { + "name": "名称", + "targetFormat": "目标格式", + "defaultBitRate": "默认比特率", + "command": "命令" + } + }, + "playlist": { + "name": "歌单", + "fields": { + "name": "名称", + "duration": "时长", + "ownerName": "所有者", + "public": "公开", + "updatedAt": "更新于", + "createdAt": "创建于", + "songCount": "歌曲数", + "comment": "注释", + "sync": "自动导入", + "path": "导入" + }, + "actions": { + "selectPlaylist": "选择歌单", + "addNewPlaylist": "新建 %{name}", + "export": "导出", + "saveQueue": "保存为歌单", + "makePublic": "设为公开", + "makePrivate": "设为私有", + "searchOrCreate": "搜索歌单,或输入名称新建…", + "pressEnterToCreate": "按 Enter 键新建歌单", + "removeFromSelection": "移除选中项" + }, + "message": { + "duplicate_song": "添加重复的歌曲", + "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?", + "noPlaylistsFound": "未找到歌单", + "noPlaylists": "暂无可用歌单" + } + }, + "radio": { + "name": "电台", + "fields": { + "name": "名称", + "streamUrl": "推流地址", + "homePageUrl": "首页链接", + "updatedAt": "更新于", + "createdAt": "创建于" + }, + "actions": { + "playNow": "开始播放" + } + }, + "share": { + "name": "分享", + "fields": { + "username": "分享者", + "url": "链接", + "description": "描述", + "downloadable": "是否允许下载?", + "contents": "目录", + "expiresAt": "过期于", + "lastVisitedAt": "上次访问于", + "visitCount": "访问数", + "format": "格式", + "maxBitRate": "最大比特率", + "updatedAt": "更新于", + "createdAt": "创建于" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "丢失文件", + "empty": "无丢失文件", + "fields": { + "path": "路径", + "size": "文件大小", + "libraryName": "媒体库", + "updatedAt": "丢失于" + }, + "actions": { + "remove": "移除", + "remove_all": "移除所有" + }, + "notifications": { + "removed": "丢失文件已移除" + } + }, + "library": { + "name": "媒体库", + "fields": { + "name": "名称", + "path": "路径", + "remotePath": "远程路径", + "lastScanAt": "上次扫描", + "songCount": "歌曲", + "albumCount": "专辑", + "artistCount": "艺术家", + "totalSongs": "歌曲", + "totalAlbums": "专辑", + "totalArtists": "艺术家", + "totalFolders": "目录", + "totalFiles": "文件", + "totalMissingFiles": "缺失的文件", + "totalSize": "总大小", + "totalDuration": "时长", + "defaultNewUsers": "新用户默认", + "createdAt": "创建于", + "updatedAt": "更新于" + }, + "sections": { + "basic": "基本信息", + "statistics": "统计数据" + }, + "actions": { + "scan": "扫描媒体库", + "manageUsers": "管理用户权限", + "viewDetails": "查看详情" + }, + "notifications": { + "created": "媒体库已创建", + "updated": "媒体库已更新", + "deleted": "媒体库已删除", + "scanStarted": "开始扫描媒体库", + "scanCompleted": "媒体库扫描已完成" + }, + "validation": { + "nameRequired": "媒体库名称不能为空!", + "pathRequired": "媒体库路径不能为空!", + "pathNotDirectory": "媒体库路径必须为目录!", + "pathNotFound": "媒体库路径不存在!", + "pathNotAccessible": "媒体库路径无法访问!", + "pathInvalid": "媒体库路径无效!" + }, + "messages": { + "deleteConfirm": "您确定要删除此媒体库吗?此操作将删除所有关联数据及用户访问权限!", + "scanInProgress": "正在扫描...", + "noLibrariesAssigned": "该用户未分配任何媒体库!" + } + } + }, + "ra": { + "auth": { + "welcome1": "感谢您安装 Navidrome!", + "welcome2": "开始使用前,请创建一个管理员账户", + "confirmPassword": "确认密码", + "buttonCreateAdmin": "创建管理员", + "auth_check_error": "请登录访问更多内容", + "user_menu": "配置", + "username": "用户名", + "password": "密码", + "sign_in": "登录", + "sign_in_error": "验证失败,请重试", + "logout": "注销", + "insightsCollectionNote": "Navidrome 会收集匿名使用数据以协助改进项目。\n点击[此处]了解详情或选择退出。" + }, + "validation": { + "invalidChars": "请使用字母和数字", + "passwordDoesNotMatch": "密码不匹配", + "required": "必填", + "minLength": "必须不少于 %{min} 个字符", + "maxLength": "必须不多于 %{max} 个字符", + "minValue": "必须不小于 %{min}", + "maxValue": "必须不大于 %{max}", + "number": "必须为数字", + "email": "必须是有效的电子邮箱", + "oneOf": "必须为: %{options} 其中一项", + "regex": "必须符合指定的格式(正则表达式):%{pattern}", + "unique": "必须唯一", + "url": "必须是有效的链接" + }, + "action": { + "add_filter": "添加筛选", + "add": "添加", + "back": "返回", + "bulk_actions": "选中 %{smart_count} 项", + "bulk_actions_mobile": "%{smart_count}", + "cancel": "取消", + "clear_input_value": "清除", + "clone": "复制", + "confirm": "确认", + "create": "新建", + "delete": "删除", + "edit": "编辑", + "export": "导出", + "list": "列表", + "refresh": "刷新", + "remove_filter": "取消筛选", + "remove": "删除", + "save": "保存", + "search": "搜索", + "show": "显示", + "sort": "排序", + "undo": "撤销", + "expand": "展开", + "close": "关闭", + "open_menu": "打开菜单", + "close_menu": "关闭菜单", + "unselect": "未选择", + "skip": "跳过", + "share": "分享", + "download": "下载" + }, + "boolean": { + "true": "是", + "false": "否" + }, + "page": { + "create": "新建 %{name}", + "dashboard": "仪表盘", + "edit": "%{name} #%{id}", + "error": "发生错误", + "list": "%{name}", + "loading": "加载中", + "not_found": "未找到", + "show": "%{name} #%{id}", + "empty": "还没有 %{name}。", + "invite": "您要创建一个吗?" + }, + "input": { + "file": { + "upload_several": "拖拽多个文件上传或点击选择一个", + "upload_single": "拖拽单个文件上传或点击选择一个" + }, + "image": { + "upload_several": "拖拽多个图片上传或点击选择一个", + "upload_single": "拖拽单个图片上传或点击选择一个" + }, + "references": { + "all_missing": "未找到参考数据", + "many_missing": "至少有一条参考数据不再可用", + "single_missing": "关联的参考数据不再可用" + }, + "password": { + "toggle_visible": "隐藏密码", + "toggle_hidden": "显示密码" + } + }, + "message": { + "about": "关于", + "are_you_sure": "您确定要进行此操作?", + "bulk_delete_content": "您确定要删除 %{smart_count} 项 %{name}?", + "bulk_delete_title": "删除 %{smart_count} 项 %{name}", + "delete_content": "您确定要删除该条目?", + "delete_title": "删除 %{name} #%{id}", + "details": "详情", + "error": "发生一个客户端错误,您的请求无法完成", + "invalid_form": "提交内容无效,请检查错误", + "loading": "正在加载页面,请稍候", + "no": "否", + "not_found": "您输入的链接格式不对或链接丢失", + "yes": "是", + "unsaved_changes": "某些更改尚未保存,您确定要离开此页面吗?" + }, + "navigation": { + "no_results": "无内容", + "no_more_results": "页码 %{page} 超出范围,尝试返回上一页", + "page_out_of_boundaries": "页码 %{page} 超出范围", + "page_out_from_end": "已经最后一页", + "page_out_from_begin": "已经是第一页", + "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}", + "page_rows_per_page": "每页行数:", + "next": "下一页", + "prev": "上一页", + "skip_nav": "跳过" + }, + "notification": { + "updated": "已更新 %{smart_count} 项", + "created": "已新建 1 项", + "deleted": "已删除 %{smart_count} 项", + "bad_item": "不正确的项", + "item_doesnt_exist": "该项不存在", + "http_error": "与服务通信出错", + "data_provider_error": "数据来源错误,请检查控制台的详细信息", + "i18n_error": "加载所选语言时出错", + "canceled": "操作已取消", + "logged_out": "您的会话已结束,请重新登录", + "new_version": "发现新版本!请刷新此页面" + }, + "toggleFieldsMenu": { + "columnsToDisplay": "显示的项", + "layout": "布局", + "grid": "网格", + "table": "表格" + } + }, + "message": { + "note": "说明", + "transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。", + "transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。", + "songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单", + "noSimilarSongsFound": "未找到相似歌曲", + "noTopSongsFound": "未找到热门歌曲", + "noPlaylistsAvailable": "没有有效的歌单", + "delete_user_title": "删除用户 %{name}", + "delete_user_content": "您确定要删除该用户及其相关数据(包括歌单和用户配置)吗?", + "remove_missing_title": "移除丢失文件", + "remove_missing_content": "您确定要将选中的丢失文件从数据库中永久移除吗?此操作将删除所有相关信息,包括播放次数和评分。", + "remove_all_missing_title": "删除所有丢失文件", + "remove_all_missing_content": "您确定要从数据库中删除所有丢失文件吗?这将永久删除对它们的所有引用,包括它们的播放次数和评分。", + "notifications_blocked": "您已在浏览器的设置中屏蔽了此网站的通知", + "notifications_not_available": "此浏览器不支持桌面通知", + "lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录", + "lastfmLinkFailure": "Last.fm 无法关联", + "lastfmUnlinkSuccess": "已成功解除与 Last.fm 的链接,且喜好记录已禁用", + "lastfmUnlinkFailure": "Last.fm 无法取消关联", + "listenBrainzLinkSuccess": "ListenBrainz 已关联并启用喜好记录", + "listenBrainzLinkFailure": "ListenBrainz 无法关联:%{error}", + "listenBrainzUnlinkSuccess": "已成功解除与 ListenBrainz 的链接,且喜好记录已禁用", + "listenBrainzUnlinkFailure": "ListenBrainz 无法取消关联", + "openIn": { + "lastfm": "在 Last.fm 中打开", + "musicbrainz": "在 MusicBrainz 中打开" + }, + "lastfmLink": "查看更多…", + "shareOriginalFormat": "分享原始格式", + "shareDialogTitle": "分享 %{resource} '%{name}'", + "shareBatchDialogTitle": "分享 %{smart_count} 个 %{resource}", + "shareCopyToClipboard": "复制到剪切板: Ctrl+C, Enter", + "shareSuccess": "分享链接已复制: %{url}", + "shareFailure": "分享链接复制失败: %{url}", + "downloadDialogTitle": "下载 %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "下载原始格式" + }, + "menu": { + "library": "曲库", + "librarySelector": { + "allLibraries": "全部媒体库 (%{count})", + "multipleLibraries": "已选 %{selected} 共 %{total} 媒体库", + "selectLibraries": "选择媒体库", + "none": "无" + }, + "settings": "设置", + "version": "版本", + "theme": "主题", + "personal": { + "name": "个性化", + "options": { + "theme": "主题", + "language": "语言", + "defaultView": "默认界面", + "desktop_notifications": "桌面通知", + "lastfmNotConfigured": "没有配置 Last.fm 的 API-Key", + "lastfmScrobbling": "启用 Last.fm 的喜好记录", + "listenBrainzScrobbling": "启用 ListenBrainz 的喜好记录", + "replaygain": "回放增益", + "preAmp": "前置放大器 (dB)", + "gain": { + "none": "禁用增益", + "album": "使用专辑增益信息", + "track": "使用歌曲增益信息" + } + } + }, + "albumList": "专辑", + "playlists": "歌单", + "sharedPlaylists": "共享的歌单", + "about": "关于" + }, + "player": { + "playListsText": "播放列表", + "openText": "打开", + "closeText": "关闭", + "notContentText": "没有音乐", + "clickToPlayText": "点击播放", + "clickToPauseText": "点击暂停", + "nextTrackText": "下一首", + "previousTrackText": "上一首", + "reloadText": "重新播放", + "volumeText": "音量", + "toggleLyricText": "切换歌词", + "toggleMiniModeText": "最小化", + "destroyText": "关闭", + "downloadText": "下载", + "removeAudioListsText": "清空播放列表", + "clickToDeleteText": "点击删除 %{name}", + "emptyLyricText": "无歌词", + "playModeText": { + "order": "顺序播放", + "orderLoop": "列表循环", + "singleLoop": "单曲循环", + "shufflePlay": "随机播放" + } + }, + "about": { + "links": { + "homepage": "主页", + "source": "源代码", + "featureRequests": "功能需求", + "lastInsightsCollection": " 最近的分析收集", + "insights": { + "disabled": "禁用", + "waiting": "等待" + } + }, + "tabs": { + "about": "关于", + "config": "配置" + }, + "config": { + "configName": "配置名称", + "environmentVariable": "环境变量", + "currentValue": "当前值", + "configurationFile": "配置文件", + "exportToml": "导出配置(TOML)", + "exportSuccess": "配置以 TOML 格式导出到剪贴板", + "exportFailed": "复制配置失败", + "devFlagsHeader": "开发标志(可能会更改/删除)", + "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除" + } + }, + "activity": { + "title": "运行情况", + "totalScanned": "已完成扫描的目录", + "quickScan": "快速扫描", + "fullScan": "完全扫描", + "serverUptime": "服务器已运行", + "serverDown": "服务器已离线", + "scanType": "扫描类型", + "status": "扫描状态", + "elapsedTime": "用时" + }, + "nowPlaying": { + "title": "正在播放", + "empty": "无播放内容", + "minutesAgo": "%{smart_count} 分钟前" + }, + "help": { + "title": "Navidrome 快捷键", + "hotkeys": { + "show_help": "显示此帮助", + "toggle_menu": "显示/隐藏菜单侧栏", + "toggle_play": "播放/暂停", + "prev_song": "上一首歌", + "next_song": "下一首歌", + "current_song": "转到当前播放", + "vol_up": "增大音量", + "vol_down": "减小音量", + "toggle_love": "添加/移除星标" + } + } +} diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json new file mode 100644 index 0000000..7d8ce28 --- /dev/null +++ b/resources/i18n/zh-Hant.json @@ -0,0 +1,630 @@ +{ + "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": "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": "點擊此處來獲得您的 ListenBrainz 權杖", + "selectAllLibraries": "選取全部媒體庫", + "adminAutoLibraries": "管理員預設可存取所有媒體庫" + } + }, + "player": { + "name": "播放器 |||| 播放器", + "fields": { + "name": "名稱", + "transcodingId": "轉碼", + "maxBitRate": "最大位元率", + "client": "客戶端", + "userName": "使用者名稱", + "lastSeen": "上次上線", + "reportRealPath": "回報實際路徑", + "scrobbleEnabled": "傳送音樂記錄至外部服務" + } + }, + "transcoding": { + "name": "轉碼 |||| 轉碼", + "fields": { + "name": "名稱", + "targetFormat": "目標格式", + "defaultBitRate": "預設位元率", + "command": "指令" + } + }, + "playlist": { + "name": "播放清單 |||| 播放清單", + "fields": { + "name": "名稱", + "duration": "長度", + "ownerName": "擁有者", + "public": "公開", + "updatedAt": "更新於", + "createdAt": "建立於", + "songCount": "歌曲數", + "comment": "註解", + "sync": "自動匯入", + "path": "匯入來源" + }, + "actions": { + "selectPlaylist": "選取播放清單:", + "addNewPlaylist": "建立「%{name}」", + "export": "匯出", + "saveQueue": "將播放佇列儲存到播放清單", + "makePublic": "設為公開", + "makePrivate": "設為私人", + "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…", + "pressEnterToCreate": "按 Enter 鍵建立新的播放清單", + "removeFromSelection": "移除選取項目" + }, + "message": { + "duplicate_song": "加入重複的歌曲", + "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?", + "noPlaylistsFound": "找不到播放清單", + "noPlaylists": "暫無播放清單" + } + }, + "radio": { + "name": "電台 |||| 電台", + "fields": { + "name": "名稱", + "streamUrl": "串流網址", + "homePageUrl": "首頁網址", + "updatedAt": "更新於", + "createdAt": "建立於" + }, + "actions": { + "playNow": "立即播放" + } + }, + "share": { + "name": "分享 |||| 分享", + "fields": { + "username": "分享者", + "url": "網址", + "description": "描述", + "downloadable": "允許下載?", + "contents": "內容", + "expiresAt": "過期時間", + "lastVisitedAt": "上次造訪時間", + "visitCount": "造訪次數", + "format": "格式", + "maxBitRate": "最大位元率", + "updatedAt": "更新於", + "createdAt": "建立於" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "遺失檔案 |||| 遺失檔案", + "empty": "無遺失檔案", + "fields": { + "path": "路徑", + "size": "檔案大小", + "libraryName": "媒體庫", + "updatedAt": "遺失於" + }, + "actions": { + "remove": "刪除", + "remove_all": "刪除所有" + }, + "notifications": { + "removed": "遺失檔案已刪除" + } + }, + "library": { + "name": "媒體庫 |||| 媒體庫", + "fields": { + "name": "名稱", + "path": "路徑", + "remotePath": "遠端路徑", + "lastScanAt": "上次掃描", + "songCount": "歌曲", + "albumCount": "專輯", + "artistCount": "藝人", + "totalSongs": "歌曲", + "totalAlbums": "專輯", + "totalArtists": "藝人", + "totalFolders": "資料夾", + "totalFiles": "檔案", + "totalMissingFiles": "遺失檔案", + "totalSize": "總大小", + "totalDuration": "時長", + "defaultNewUsers": "新使用者預設媒體庫", + "createdAt": "建立於", + "updatedAt": "更新於" + }, + "sections": { + "basic": "基本資訊", + "statistics": "統計" + }, + "actions": { + "scan": "掃描媒體庫", + "manageUsers": "管理使用者權限", + "viewDetails": "查看詳細資料" + }, + "notifications": { + "created": "成功建立媒體庫", + "updated": "成功更新媒體庫", + "deleted": "成功刪除媒體庫", + "scanStarted": "開始掃描媒體庫", + "scanCompleted": "媒體庫掃描完成" + }, + "validation": { + "nameRequired": "請輸入媒體庫名稱", + "pathRequired": "請提供媒體庫路徑", + "pathNotDirectory": "媒體庫路徑必須為目錄", + "pathNotFound": "媒體庫路徑不存在", + "pathNotAccessible": "無法存取媒體庫路徑", + "pathInvalid": "媒體庫路徑無效" + }, + "messages": { + "deleteConfirm": "您確定要刪除此媒體庫嗎?這將刪除所有相關資料和使用者存取權限。", + "scanInProgress": "正在掃描...", + "noLibrariesAssigned": "沒有為該使用者指派任何媒體庫" + } + } + }, + "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} 項", + "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": "您輸入了錯誤的連結或連結遺失", + "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} 首歌到播放清單", + "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": "已成功以 %{user} 身份連接 ListenBrainz 並開啟音樂記錄", + "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}", + "shareFailure": "分享連結複製失敗:%{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": "1 分鐘前 |||| %{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/logo-192x192.png b/resources/logo-192x192.png new file mode 100644 index 0000000..1fa2234 Binary files /dev/null and b/resources/logo-192x192.png differ diff --git a/resources/mappings.yaml b/resources/mappings.yaml new file mode 100644 index 0000000..d1da5c6 --- /dev/null +++ b/resources/mappings.yaml @@ -0,0 +1,257 @@ +#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, version] + 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 ] + maxLength: 32768 + 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 ] + 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 new file mode 100644 index 0000000..f67b26a --- /dev/null +++ b/resources/mime_types.yaml @@ -0,0 +1,51 @@ +# 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/mp4 + .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 + +# Image + .gif: image/gif + .jpg: image/jpeg + .jpeg: image/jpeg + .webp: image/webp + .png: image/png + .bmp: image/bmp + +# 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/scanner/README.md b/scanner/README.md new file mode 100644 index 0000000..b2c6823 --- /dev/null +++ b/scanner/README.md @@ -0,0 +1,466 @@ +# 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/controller.go b/scanner/controller.go new file mode 100644 index 0000000..b42246a --- /dev/null +++ b/scanner/controller.go @@ -0,0 +1,310 @@ +package scanner + +import ( + "context" + "errors" + "fmt" + "sync/atomic" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server/events" + . "github.com/navidrome/navidrome/utils/gg" + "github.com/navidrome/navidrome/utils/pl" + "golang.org/x/time/rate" +) + +var ( + ErrAlreadyScanning = errors.New("already scanning") +) + +func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker, + pls core.Playlists, m metrics.Metrics) model.Scanner { + c := &controller{ + rootCtx: rootCtx, + ds: ds, + cw: cw, + broker: broker, + pls: pls, + metrics: m, + } + if !conf.Server.DevExternalScanner { + c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) + } + return c +} + +func (s *controller) getScanner() scanner { + if conf.Server.DevExternalScanner { + return &scannerExternal{} + } + return &scannerImpl{ds: s.ds, cw: s.cw, 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 core.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, cw: artwork.NoopCacheWarmer(), 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 + ds model.DataStore + cw artwork.CacheWarmer + broker events.Broker + metrics metrics.Metrics + pls core.Playlists + limiter *rate.Sometimes + count atomic.Uint32 + folderCount atomic.Uint32 + changesDetected bool +} + +// 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(ctx).GetAll(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(ctx).DefaultGet(consts.LastScanErrorKey, "") + scanType, _ = s.ds.Property(ctx).DefaultGet(consts.LastScanTypeKey, "") + startTimeStr, _ := s.ds.Property(ctx).DefaultGet(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(ctx).GetAll() + 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) + + // 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)) + } + // 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 + +func lockScan(ctx context.Context) (func(), error) { + if !running.CompareAndSwap(false, true) { + log.Debug(ctx, "Scanner already running, ignoring request") + return func() {}, ErrAlreadyScanning + } + return func() { + running.Store(false) + }, nil +} + +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) +} diff --git a/scanner/controller_test.go b/scanner/controller_test.go new file mode 100644 index 0000000..f5ccabc --- /dev/null +++ b/scanner/controller_test.go @@ -0,0 +1,56 @@ +package scanner_test + +import ( + "context" + + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/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, artwork.NoopCacheWarmer(), events.NoopBroker(), core.NewPlaylists(ds), metrics.NewNoopInstance()) + }) + + It("includes last scan error", func() { + Expect(ds.Property(ctx).Put(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(ctx).Put(consts.LastScanErrorKey, "test error")).To(Succeed()) + Expect(ds.Property(ctx).Put(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")) + }) + }) +}) diff --git a/scanner/external.go b/scanner/external.go new file mode 100644 index 0000000..f5a117e --- /dev/null +++ b/scanner/external.go @@ -0,0 +1,101 @@ +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" +) + +// 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, + "--cachefolder", conf.Server.CacheFolder, + } + + // Add targets if provided + if len(targets) > 0 { + for _, target := range targets { + args = append(args, "-t", target.String()) + } + log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets) + } 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 { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + _ = 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() +} + +var _ scanner = (*scannerExternal)(nil) diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go new file mode 100644 index 0000000..9d8d0c5 --- /dev/null +++ b/scanner/folder_entry.go @@ -0,0 +1,118 @@ +package scanner + +import ( + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "io/fs" + "maps" + "slices" + "time" + + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/chrono" +) + +func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry { + f := &folderEntry{ + id: id, + job: job, + path: path, + audioFiles: make(map[string]fs.DirEntry), + imageFiles: make(map[string]fs.DirEntry), + albumIDMap: make(map[string]string), + updTime: updTime, + prevHash: hash, + } + 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 + numPlaylists int + numSubFolders int + imagesUpdatedAt time.Time + prevHash string // Previous hash from DB + tracks model.MediaFiles + albums model.Albums + albumIDMap map[string]string + artists model.Artists + tags model.TagList + missingTracks []*model.MediaFile +} + +func (f *folderEntry) hasNoFiles() bool { + return len(f.audioFiles) == 0 && len(f.imageFiles) == 0 && f.numPlaylists == 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() +} + +func (f *folderEntry) toFolder() *model.Folder { + folder := model.NewFolder(f.job.lib, f.path) + folder.NumAudioFiles = len(f.audioFiles) + if core.InPlaylistsPath(*folder) { + folder.NumPlaylists = f.numPlaylists + } + 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(), + f.numPlaylists, + f.numSubFolders, + f.imagesUpdatedAt.UTC(), + ) + + // Sort the keys of audio and image 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) + + // 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()) + } + } + + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go new file mode 100644 index 0000000..0328c66 --- /dev/null +++ b/scanner/folder_entry_test.go @@ -0,0 +1,543 @@ +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.UpdatedAt, updateInfo.Hash) + + 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.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, time.Time{}, "") + }) + + 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.numPlaylists = 1 + 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.numPlaylists = 2 + 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.numPlaylists = 3 + 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.numPlaylists = 2 + 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"}, + } + + 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 count changes", func() { + hash1 := entry.hash() + + entry.numPlaylists = 5 + 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 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.numPlaylists = 10 // 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.numPlaylists = 10 // 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, time.Time{}, "") + + // 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 new file mode 100644 index 0000000..da74293 --- /dev/null +++ b/scanner/ignore_checker.go @@ -0,0 +1,163 @@ +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.Split(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 new file mode 100644 index 0000000..5378ed4 --- /dev/null +++ b/scanner/ignore_checker_test.go @@ -0,0 +1,313 @@ +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/metadata_old/ffmpeg/ffmpeg.go b/scanner/metadata_old/ffmpeg/ffmpeg.go new file mode 100644 index 0000000..8fc496c --- /dev/null +++ b/scanner/metadata_old/ffmpeg/ffmpeg.go @@ -0,0 +1,211 @@ +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_old" +) + +const ExtractorID = "ffmpeg" + +type Extractor struct { + ffmpeg ffmpeg.FFmpeg +} + +func (e *Extractor) Parse(files ...string) (map[string]metadata_old.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_old.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_old.ParsedTags { + return metadata_old.ParsedTags{ + "disc": {"tpa"}, + "has_picture": {"metadata_block_picture"}, + "originaldate": {"tdor"}, + } +} + +func (e *Extractor) Version() string { + return e.ffmpeg.Version() +} + +func (e *Extractor) extractMetadata(filePath, info string) (metadata_old.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 + // Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s + 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["samplerate"] = []string{match[2]} + 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_old.RegisterExtractor(ExtractorID, &Extractor{ffmpeg: ffmpeg.New()}) +} diff --git a/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go b/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go new file mode 100644 index 0000000..8159403 --- /dev/null +++ b/scanner/metadata_old/ffmpeg/ffmpeg_suite_test.go @@ -0,0 +1,17 @@ +package ffmpeg + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFFMpeg(t *testing.T) { + tests.Init(t, true) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "FFMpeg Suite") +} diff --git a/scanner/metadata_old/ffmpeg/ffmpeg_test.go b/scanner/metadata_old/ffmpeg/ffmpeg_test.go new file mode 100644 index 0000000..6c7f43a --- /dev/null +++ b/scanner/metadata_old/ffmpeg/ffmpeg_test.go @@ -0,0 +1,375 @@ +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(SatisfyAll( + HaveKeyWithValue("catalognumber", []string{"PLD 1201"}), + HaveKeyWithValue("musicbrainz_trackid", []string{"ffe06940-727a-415a-b608-b7e45737f9d8"}), + HaveKeyWithValue("musicbrainz_albumid", []string{"71eb5e4a-90e2-4a31-a2d1-a96485fcb667"}), + HaveKeyWithValue("musicbrainz_artistid", []string{"1f9df192-a621-4f54-8850-2c5373b7eac9"}), + HaveKeyWithValue("musicbrainz_albumartistid", []string{"89ad4ac3-39f7-470e-963a-56509c546377"}), + HaveKeyWithValue("musicbrainz_albumtype", []string{"album"}), + 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("parse sampleRate from the stream", func() { + const output = ` +Input #0, dsf, from '/Users/deluan/Downloads/06-04 Perpetual Change.dsf': + Duration: 00:14:19.46, start: 0.000000, bitrate: 5644 kb/s + Stream #0:0: Audio: dsd_lsbf_planar, 352800 Hz, stereo, fltp, 5644 kb/s` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("samplerate", []string{"352800"})) + }) + + It("parse sampleRate 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("samplerate", []string{"48000"})) + }) + + 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(SatisfyAll( + HaveKeyWithValue("title", []string{"ドツペルゲンガー"}), + HaveKeyWithValue("album", []string{"加爾基 精液 栗ノ花"}), + HaveKeyWithValue("artist", []string{"椎名林檎"}), + HaveKeyWithValue("album_artist", []string{"椎名林檎"}), + HaveKeyWithValue("title-sort", []string{"Dopperugengā"}), + HaveKeyWithValue("albumsort", []string{"Kalk Samen Kuri No Hana"}), + HaveKeyWithValue("artist_sort", []string{"Shiina, Ringo"}), + 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(SatisfyAll( + HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}), + HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}), + HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}), + HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}), + )) + }) + + It("parses lyrics with language code", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + lyrics-eng : [00:00.00]This is + : [00:02.50]English + lyrics-xxx : [00:00.00]This is + : [00:02.50]unspecified + ` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(SatisfyAll( + HaveKeyWithValue("lyrics-eng", []string{ + "[00:00.00]This is\n[00:02.50]English", + }), + HaveKeyWithValue("lyrics-xxx", []string{ + "[00:00.00]This is\n[00:02.50]unspecified", + }), + )) + }) + + It("parses normal LYRICS tag", func() { + const output = ` + Input #0, mp3, from 'test.mp3': + Metadata: + LYRICS : [00:00.00]This is + : [00:02.50]English + ` + md, _ := e.extractMetadata("tests/fixtures/test.mp3", output) + Expect(md).To(HaveKeyWithValue("lyrics", []string{ + "[00:00.00]This is\n[00:02.50]English", + })) + }) +}) diff --git a/scanner/metadata_old/metadata.go b/scanner/metadata_old/metadata.go new file mode 100644 index 0000000..6530ee8 --- /dev/null +++ b/scanner/metadata_old/metadata.go @@ -0,0 +1,408 @@ +package metadata_old + +import ( + "encoding/json" + "fmt" + "math" + "os" + "path" + "regexp" + "strconv" + "strings" + "time" + + "github.com/djherbis/times" + "github.com/google/uuid" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +type Extractor interface { + Parse(files ...string) (map[string]ParsedTags, error) + CustomMappings() ParsedTags + Version() string +} + +var extractors = map[string]Extractor{} + +func RegisterExtractor(id string, parser Extractor) { + extractors[id] = parser +} + +func LogExtractors() { + for id, p := range extractors { + log.Debug("Registered metadata extractor", "id", id, "version", p.Version()) + } +} + +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 { + for t, values := range tags { + values = removeDuplicatesAndEmpty(values) + if len(values) == 0 { + delete(tags, t) + continue + } + tags[t] = values + } + return Tags{ + filePath: filePath, + fileInfo: fileInfo, + Tags: tags, + } +} + +func removeDuplicatesAndEmpty(values []string) []string { + encountered := map[string]struct{}{} + empty := true + result := make([]string, 0, len(values)) + for _, v := range values { + if _, ok := encountered[v]; ok { + continue + } + encountered[v] = struct{}{} + empty = empty && v == "" + result = append(result, v) + } + if empty { + return nil + } + return result +} + +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("tsot", "title", "name") } +func (t Tags) SortAlbum() string { return t.getSortTag("tsoa", "album") } +func (t Tags) SortArtist() string { return t.getSortTag("tsop", "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) Compilation() bool { return t.getBool("tcmp", "compilation", "wm/iscompilation") } +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) MbzRecordingID() 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") +} + +// Gain Properties + +func (t Tags) RGAlbumGain() float64 { + return t.getGainValue("replaygain_album_gain", "r128_album_gain") +} +func (t Tags) RGAlbumPeak() float64 { return t.getPeakValue("replaygain_album_peak") } +func (t Tags) RGTrackGain() float64 { + return t.getGainValue("replaygain_track_gain", "r128_track_gain") +} +func (t Tags) RGTrackPeak() float64 { return t.getPeakValue("replaygain_track_peak") } + +// File properties + +func (t Tags) Duration() float32 { return float32(t.getFloat("duration")) } +func (t Tags) SampleRate() int { return t.getInt("samplerate") } +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), ".")) } +func (t Tags) BirthTime() time.Time { + if ts := times.Get(t.fileInfo); ts.HasBirthTime() { + return ts.BirthTime() + } + return time.Now() +} + +func (t Tags) Lyrics() string { + lyricList := model.LyricList{} + basicLyrics := t.getAllTagValues("lyrics", "unsynced_lyrics", "unsynced lyrics", "unsyncedlyrics") + + for _, value := range basicLyrics { + lyrics, err := model.ToLyrics("xxx", value) + if err != nil { + log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) + continue + } + + lyricList = append(lyricList, *lyrics) + } + + for tag, value := range t.Tags { + if strings.HasPrefix(tag, "lyrics-") { + language := strings.TrimSpace(strings.TrimPrefix(tag, "lyrics-")) + + if language == "" { + language = "xxx" + } + + for _, text := range value { + lyrics, err := model.ToLyrics(language, text) + if err != nil { + log.Warn("Unexpected failure occurred when parsing lyrics", "file", t.filePath, "error", err) + continue + } + + lyricList = append(lyricList, *lyrics) + } + } + } + + res, err := json.Marshal(lyricList) + if err != nil { + log.Warn("Unexpected error occurred when serializing lyrics", "file", t.filePath, "error", err) + return "" + } + return string(res) +} + +func (t Tags) getGainValue(rgTagName, r128TagName string) float64 { + // Check for ReplayGain first + // ReplayGain is in the form [-]a.bb dB and normalized to -18dB + var tag = t.getFirstTagValue(rgTagName) + if tag != "" { + tag = strings.TrimSpace(strings.Replace(tag, "dB", "", 1)) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil || value == math.Inf(-1) || value == math.Inf(1) { + return 0 + } + return value + } + + // If ReplayGain is not found, check for R128 gain + // R128 gain is a Q7.8 fixed point number normalized to -23dB + tag = t.getFirstTagValue(r128TagName) + if tag != "" { + var iValue, err = strconv.Atoi(tag) + if err != nil { + return 0 + } + // Convert Q7.8 to float + var value = float64(iValue) / 256.0 + // Adding 5 dB to normalize with ReplayGain level + return value + 5 + } + + return 0 +} + +func (t Tags) getPeakValue(tagName string) float64 { + var tag = t.getFirstTagValue(tagName) + var value, err = strconv.ParseFloat(tag, 64) + if err != nil || value == math.Inf(-1) || value == math.Inf(1) { + // A default of 1 for peak value results 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 { + values := make([]string, 0, len(tagNames)*2) + 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 := make([]string, 1, len(tagNames)*len(formats)+1) + all[0] = 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_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go new file mode 100644 index 0000000..2d21e07 --- /dev/null +++ b/scanner/metadata_old/metadata_internal_test.go @@ -0,0 +1,144 @@ +package metadata_old + +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.MbzRecordingID()).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.MbzRecordingID()).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("removeDuplicatesAndEmpty", func() { + It("removes duplicates", func() { + md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ + "genre": []string{"pop", "rock", "pop"}, + "date": []string{"2023-03-01", "2023-03-01"}, + "mood": []string{"happy", "sad"}, + }) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).To(HaveKeyWithValue("date", []string{"2023-03-01"})) + Expect(md.Tags).To(HaveKeyWithValue("mood", []string{"happy", "sad"})) + }) + It("removes empty tags", func() { + md := NewTag("/music/artist/album01/Song.mp3", nil, ParsedTags{ + "genre": []string{"pop", "rock", "pop"}, + "mood": []string{"", ""}, + }) + Expect(md.Tags).To(HaveKeyWithValue("genre", []string{"pop", "rock"})) + Expect(md.Tags).ToNot(HaveKey("mood")) + }) + }) + + 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)) + }) + }) + + Describe("ReplayGain", func() { + DescribeTable("getGainValue", + func(tag string, expected float64) { + md := &Tags{} + md.Tags = map[string][]string{"replaygain_track_gain": {tag}} + Expect(md.RGTrackGain()).To(Equal(expected)) + + }, + Entry("0", "0", 0.0), + Entry("1.2dB", "1.2dB", 1.2), + Entry("Infinity", "Infinity", 0.0), + Entry("Invalid value", "INVALID VALUE", 0.0), + ) + DescribeTable("getPeakValue", + func(tag string, expected float64) { + md := &Tags{} + md.Tags = map[string][]string{"replaygain_track_peak": {tag}} + Expect(md.RGTrackPeak()).To(Equal(expected)) + + }, + Entry("0", "0", 0.0), + Entry("0.5", "0.5", 0.5), + Entry("Invalid dB suffix", "0.7dB", 1.0), + Entry("Infinity", "Infinity", 1.0), + Entry("Invalid value", "INVALID VALUE", 1.0), + ) + DescribeTable("getR128GainValue", + func(tag string, expected float64) { + md := &Tags{} + md.Tags = map[string][]string{"r128_track_gain": {tag}} + Expect(md.RGTrackGain()).To(Equal(expected)) + + }, + Entry("0", "0", 5.0), + Entry("-3776", "-3776", -9.75), + Entry("Infinity", "Infinity", 0.0), + Entry("Invalid value", "INVALID VALUE", 0.0), + ) + }) +}) diff --git a/scanner/metadata_old/metadata_suite_test.go b/scanner/metadata_old/metadata_suite_test.go new file mode 100644 index 0000000..03ec3c8 --- /dev/null +++ b/scanner/metadata_old/metadata_suite_test.go @@ -0,0 +1,17 @@ +package metadata_old + +import ( + "testing" + + "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") +} diff --git a/scanner/metadata_old/metadata_test.go b/scanner/metadata_old/metadata_test.go new file mode 100644 index 0000000..444bb7f --- /dev/null +++ b/scanner/metadata_old/metadata_test.go @@ -0,0 +1,95 @@ +package metadata_old_test + +import ( + "cmp" + "encoding/json" + "slices" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core/ffmpeg" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/scanner/metadata_old" + _ "github.com/navidrome/navidrome/scanner/metadata_old/ffmpeg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Tags", func() { + var zero int64 = 0 + var secondTs int64 = 2500 + + makeLyrics := func(synced bool, lang, secondLine string) model.Lyrics { + lines := []model.Line{ + {Value: "This is"}, + {Value: secondLine}, + } + + if synced { + lines[0].Start = &zero + lines[1].Start = &secondTs + } + + lyrics := model.Lyrics{ + Lang: lang, + Line: lines, + Synced: synced, + } + + return lyrics + } + + sortLyrics := func(lines model.LyricList) model.LyricList { + slices.SortFunc(lines, func(a, b model.Lyrics) int { + langDiff := cmp.Compare(a.Lang, b.Lang) + if langDiff != 0 { + return langDiff + } + return cmp.Compare(a.Line[1].Value, b.Line[1].Value) + }) + + return lines + } + + compareLyrics := func(m metadata_old.Tags, expected model.LyricList) { + lyrics := model.LyricList{} + Expect(json.Unmarshal([]byte(m.Lyrics()), &lyrics)).To(BeNil()) + Expect(sortLyrics(lyrics)).To(Equal(sortLyrics(expected))) + } + + // Only run these tests if FFmpeg is available + FFmpegContext := XContext + if ffmpeg.New().IsAvailable() { + FFmpegContext = Context + } + FFmpegContext("Extract with FFmpeg", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.Scanner.Extractor = "ffmpeg" + }) + + DescribeTable("Lyrics test", + func(file string) { + path := "tests/fixtures/" + file + mds, err := metadata_old.Extract(path) + Expect(err).ToNot(HaveOccurred()) + Expect(mds).To(HaveLen(1)) + + m := mds[path] + compareLyrics(m, model.LyricList{ + makeLyrics(true, "eng", "English"), + makeLyrics(true, "xxx", "unspecified"), + }) + }, + + Entry("Parses AIFF file", "test.aiff"), + Entry("Parses MP3 files", "test.mp3"), + // Disabled, because it fails in pipeline + // Entry("Parses WAV files", "test.wav"), + + // FFMPEG behaves very weirdly for multivalued tags for non-ID3 + // Specifically, they are separated by ";, which is indistinguishable + // from other fields + ) + }) +}) diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go new file mode 100644 index 0000000..3290299 --- /dev/null +++ b/scanner/phase_1_folders.go @@ -0,0 +1,501 @@ +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/artwork" + "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, cw artwork.CacheWarmer) *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, cw, lib, state.fullScan, targetFolders) + if err != nil { + log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err) + state.sendWarning(err.Error()) + continue + } + jobs = append(jobs, job) + } + + return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state} +} + +type scanJob struct { + lib model.Library + fs storage.MusicFS + cw artwork.CacheWarmer + 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) + lock sync.Mutex + numFolders atomic.Int64 +} + +func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) { + // Get folder updates, optionally filtered to specific target folders + lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(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) + } + return &scanJob{ + lib: lib, + fs: fsys, + cw: cw, + lastUpdates: lastUpdates, + targetFolders: targetFolders, + }, 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.UpdatedAt, info.Hash) +} + +// 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 + state *scanState + prevAlbumPIDConf string +} + +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 { + var err error + p.prevAlbumPIDConf, err = p.ds.Property(p.ctx).DefaultGet(consts.PIDAlbumKey, "") + if err != nil { + return fmt.Errorf("getting album PID conf: %w", err) + } + + // TODO Parallelize multiple job when we have multiple libraries + var total int64 + var totalChanged int64 + for _, job := range p.jobs { + if utils.IsCtxDone(p.ctx) { + break + } + + outputChan, err := walkDirTree(p.ctx, 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.ctx, 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", folder.numPlaylists, "numSubfolders", folder.numSubFolders) + + // Check if folder is outdated + if folder.isOutdated() { + if !p.state.fullScan { + if folder.hasNoFiles() && folder.isNew() { + log.Trace(p.ctx, "Scanner: Skipping new folder with no files", "folder", folder.path, "lib", job.lib.Name) + 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)), + ppl.NewStage(p.persistChanges, ppl.Name("persist changes")), + ppl.NewStage(p.logFolder, ppl.Name("log results")), + } +} + +func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) { + defer p.measure(entry)() + + // Load children mediafiles from DB + cursor, err := p.ds.MediaFile(p.ctx).GetCursor(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 || p.state.fullScan { + 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.ID, 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 := "" + if prev := toImport[filePath]; prev != nil { + prevAlbumID = prev.AlbumID + } else { + prevAlbumID = md.AlbumID(track, p.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) + + // Collect artwork IDs to pre-cache after the transaction commits + var artworkIDs []model.ArtworkID + + err := p.ds.WithTx(func(tx model.DataStore) error { + // Instantiate all repositories just once per folder + folderRepo := tx.Folder(p.ctx) + tagRepo := tx.Tag(p.ctx) + artistRepo := tx.Artist(p.ctx) + libraryRepo := tx.Library(p.ctx) + albumRepo := tx.Album(p.ctx) + mfRepo := tx.MediaFile(p.ctx) + + // Save folder to DB + folder := entry.toFolder() + err := folderRepo.Put(folder) + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting folder to DB", "folder", entry.path, err) + return err + } + + // Save all tags to DB + err = tagRepo.Add(entry.job.lib.ID, entry.tags...) + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting tags to DB", "folder", entry.path, err) + return 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(&entry.artists[i], "name", + "mbz_artist_id", "sort_artist_name", "order_artist_name", "full_text", "updated_at") + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting artist to DB", "folder", entry.path, "artist", entry.artists[i].Name, err) + return err + } + err = libraryRepo.AddArtist(entry.job.lib.ID, entry.artists[i].ID) + if err != nil { + log.Error(p.ctx, "Scanner: Error adding artist to library", "lib", entry.job.lib.ID, "artist", entry.artists[i].Name, err) + return err + } + if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists { + artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID()) + } + } + + // 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], entry.albumIDMap) + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting album to DB", "folder", entry.path, "album", entry.albums[i], err) + return err + } + if entry.albums[i].Name != consts.UnknownAlbum { + artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID()) + } + } + + // Save all tracks to DB + for i := range entry.tracks { + err = mfRepo.Put(&entry.tracks[i]) + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting mediafile to DB", "folder", entry.path, "track", entry.tracks[i], err) + return err + } + } + + // Mark all missing tracks as not available + if len(entry.missingTracks) > 0 { + err = mfRepo.MarkMissing(true, entry.missingTracks...) + if err != nil { + log.Error(p.ctx, "Scanner: Error marking missing tracks", "folder", entry.path, err) + return 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(albumsToUpdate...) + if err != nil { + log.Error(p.ctx, "Scanner: Error touching album", "folder", entry.path, "albums", albumsToUpdate, err) + return err + } + } + return nil + }, "scanner: persist changes") + if err != nil { + log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err) + } + + // Pre-cache artwork after the transaction commits successfully + if err == nil { + for _, artID := range artworkIDs { + entry.job.cw.PreCache(artID) + } + } + + return entry, err +} + +// 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(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(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(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", entry.numPlaylists, + "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 { + errF := p.ds.WithTx(func(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)) + err := tx.Folder(p.ctx).MarkMissing(true, folderIDs...) + if err != nil { + log.Error(p.ctx, "Scanner: Error marking missing folders", "lib", job.lib.Name, err) + return err + } + err = tx.MediaFile(p.ctx).MarkMissingByFolder(true, folderIDs...) + if err != nil { + log.Error(p.ctx, "Scanner: Error marking tracks in missing folders", "lib", job.lib.Name, err) + return err + } + // Touch all albums that have missing folders, so they get refreshed in later phases + _, err = tx.Album(p.ctx).TouchByMissingFolder() + if err != nil { + log.Error(p.ctx, "Scanner: Error touching albums with missing folders", "lib", job.lib.Name, err) + return err + } + } + return nil + }, "scanner: finalize phaseFolders") + return errors.Join(err, errF) +} + +var _ phase[*folderEntry] = (*phaseFolders)(nil) diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go new file mode 100644 index 0000000..de93ed6 --- /dev/null +++ b/scanner/phase_2_missing_tracks.go @@ -0,0 +1,344 @@ +package scanner + +import ( + "context" + "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 + 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(p.ctx).GetMissingAndMatching(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 + + for _, ms := range in.missing { + var exactMatch model.MediaFile + var equivalentMatch model.MediaFile + + // Identify exact and equivalent matches + for _, mt := range in.matched { + 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 + } + 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 { + 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 + } + 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 + } + 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 + } + + 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(p.ctx).FindRecentFilesByMBZTrackID(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(p.ctx).FindRecentFilesByProperties(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 { + return p.ds.WithTx(func(tx model.DataStore) error { + discardedID := target.ID + oldAlbumID := missing.AlbumID + newAlbumID := target.AlbumID + + // Update the target media file with the missing file's ID. This effectively "moves" the track + // to the new location while keeping its annotations and references intact. + target.ID = missing.ID + err := tx.MediaFile(p.ctx).Put(&target) + if err != nil { + return fmt.Errorf("update matched track: %w", err) + } + + // Discard the new mediafile row (the one that was moved to) + err = tx.MediaFile(p.ctx).Delete(discardedID) + if err != nil { + return fmt.Errorf("delete discarded track: %w", err) + } + + // Handle album annotation reassignment if AlbumID changed + if oldAlbumID != newAlbumID { + // Use newAlbumID as key since we only care about avoiding duplicate reassignments to the same target + p.annotationMutex.RLock() + alreadyProcessed := p.processedAlbumAnnotations[newAlbumID] + p.annotationMutex.RUnlock() + + if !alreadyProcessed { + p.annotationMutex.Lock() + // Double-check pattern to avoid race conditions + if !p.processedAlbumAnnotations[newAlbumID] { + // Reassign direct album annotations (starred, rating) + log.Debug(p.ctx, "Scanner: Reassigning album annotations", "from", oldAlbumID, "to", newAlbumID) + if err := tx.Album(p.ctx).ReassignAnnotation(oldAlbumID, newAlbumID); err != nil { + log.Warn(p.ctx, "Scanner: Could not reassign album annotations", "from", oldAlbumID, "to", newAlbumID, err) + } + + // Note: RefreshPlayCounts will be called in later phases, so we don't need to call it here + p.processedAlbumAnnotations[newAlbumID] = true + } + p.annotationMutex.Unlock() + } else { + log.Trace(p.ctx, "Scanner: Skipping album annotation reassignment", "from", oldAlbumID, "to", newAlbumID) + } + } + + 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 { + deletedCount, err := p.ds.MediaFile(p.ctx).DeleteAllMissing() + 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 new file mode 100644 index 0000000..e709004 --- /dev/null +++ b/scanner/phase_2_missing_tracks_test.go @@ -0,0 +1,769 @@ +package scanner + +import ( + "context" + "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)}}, + } + 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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + in := &missingTracks{ + missing: []model.MediaFile{missingTrack}, + matched: []model.MediaFile{matchedTrack}, + } + + _, err := phase.processMissingTracks(in) + Expect(err).ToNot(HaveOccurred()) + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + movedTrack, _ := ds.MediaFile(ctx).Get("1") + Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) + }) + + 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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + in := &missingTracks{ + missing: []model.MediaFile{missingTrack}, + matched: []model.MediaFile{matchedTrack}, + } + + _, err := phase.processMissingTracks(in) + Expect(err).ToNot(HaveOccurred()) + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + movedTrack, _ := ds.MediaFile(ctx).Get("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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedTrack) + + in := &missingTracks{ + missing: []model.MediaFile{missingTrack}, + matched: []model.MediaFile{matchedTrack}, + } + + _, err := phase.processMissingTracks(in) + Expect(err).ToNot(HaveOccurred()) + Expect(phase.totalMatched.Load()).To(Equal(uint32(1))) + Expect(state.changesDetected.Load()).To(BeTrue()) + + movedTrack, _ := ds.MediaFile(ctx).Get("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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matchedEquivalent) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get("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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&matched1) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get("1") + Expect(movedTrack.Path).To(Equal(missingTrack.Path)) + Expect(movedTrack.Title).To(Equal(missingTrack.Title)) + Expect(movedTrack.Size).To(Equal(missingTrack.Size)) + }) + + 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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&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(ctx).Delete("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.CountAllValue = 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.CountAllValue = 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.CountAllValue = 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 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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get("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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get("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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&mbzTrack) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get("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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get("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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&match1) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get("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()) + }) + }) + + Describe("Album Annotation Reassignment", func() { + var ( + albumRepo *tests.MockAlbumRepo + missingTrack model.MediaFile + matchedTrack model.MediaFile + oldAlbumID string + newAlbumID string + ) + + BeforeEach(func() { + albumRepo = ds.Album(ctx).(*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(ctx).Put(&missingTrack) + _ = ds.MediaFile(ctx).Put(&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(ctx).Get(missingTrack.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(movedTrack.Path).To(Equal(matchedTrack.Path)) + }) + }) + }) +}) diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go new file mode 100644 index 0000000..33e0fed --- /dev/null +++ b/scanner/phase_3_refresh_albums.go @@ -0,0 +1,148 @@ +// 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 + 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(p.ctx).GetTouchedAlbums(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(p.ctx).GetAll(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.Album(p.ctx).Put(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() + cnt, err := p.ds.Album(p.ctx).RefreshPlayCounts() + 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() + cnt, err = p.ds.Artist(p.ctx).RefreshPlayCounts() + 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 new file mode 100644 index 0000000..1f0baf4 --- /dev/null +++ b/scanner/phase_3_refresh_albums_test.go @@ -0,0 +1,135 @@ +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()).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("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 new file mode 100644 index 0000000..c98b51e --- /dev/null +++ b/scanner/phase_4_playlists.go @@ -0,0 +1,130 @@ +package scanner + +import ( + "context" + "fmt" + "os" + "strings" + "sync/atomic" + "time" + + ppl "github.com/google/go-pipeline/pkg/pipeline" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +type phasePlaylists struct { + ctx context.Context + scanState *scanState + ds model.DataStore + pls core.Playlists + cw artwork.CacheWarmer + refreshed atomic.Uint32 +} + +func createPhasePlaylists(ctx context.Context, scanState *scanState, ds model.DataStore, pls core.Playlists, cw artwork.CacheWarmer) *phasePlaylists { + return &phasePlaylists{ + ctx: ctx, + scanState: scanState, + ds: ds, + pls: pls, + cw: cw, + } +} + +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 + } + u, _ := request.UserFrom(p.ctx) + if !u.IsAdmin { + log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ + "Please create an admin user first, and then update the playlists for them to be imported") + return nil + } + + count := 0 + cursor, err := p.ds.Folder(p.ctx).GetTouchedWithPlaylists() + if err != nil { + return fmt.Errorf("loading touched folders: %w", err) + } + log.Debug(p.ctx, "Scanner: Checking playlists that may need refresh") + for folder, err := range cursor { + if err != nil { + return fmt.Errorf("loading touched folder: %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 +} + +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.ImportFile(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)) + } + p.cw.PreCache(pls.CoverArtID()) + 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) + } + 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 new file mode 100644 index 0000000..218aa3c --- /dev/null +++ b/scanner/phase_4_playlists_test.go @@ -0,0 +1,164 @@ +package scanner + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + + "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/model" + "github.com/navidrome/navidrome/model/request" + "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 + cw artwork.CacheWarmer + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.AutoImportPlaylists = true + ctx = context.Background() + ctx = request.WithUser(ctx, model.User{ID: "123", IsAdmin: true}) + folderRepo = &mockFolderRepository{} + ds = &tests.MockDataStore{ + MockedFolder: folderRepo, + } + pls = &mockPlaylists{} + cw = artwork.NoopCacheWarmer() + state = &scanState{} + phase = createPhasePlaylists(ctx, state, ds, pls, cw) + }) + + 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"))) + }) + }) + + 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("ImportFile", mock.Anything, folder, "playlist1.m3u"). + Return(&model.Playlist{}, nil) + pls.On("ImportFile", 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("reports an error if there is an error reading files", func() { + 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 + core.Playlists +} + +func (p *mockPlaylists) ImportFile(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 +} + +func (f *mockFolderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error) { + return func(yield func(model.Folder, error) bool) { + for folder, err := range f.data { + if err != nil { + if !yield(model.Folder{}, err) { + return + } + continue + } + if !yield(*folder, err) { + return + } + } + }, nil +} + +func (f *mockFolderRepository) SetData(m map[*model.Folder]error) { + f.data = m +} diff --git a/scanner/scanner.go b/scanner/scanner.go new file mode 100644 index 0000000..20f3f5d --- /dev/null +++ b/scanner/scanner.go @@ -0,0 +1,374 @@ +package scanner + +import ( + "context" + "fmt" + "maps" + "slices" + "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" + "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/utils/run" + "github.com/navidrome/navidrome/utils/slice" +) + +type scannerImpl struct { + ds model.DataStore + cw artwork.CacheWarmer + pls core.Playlists +} + +// 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 +} + +func (s *scanState) sendProgress(info *ProgressInfo) { + if s.progress != nil { + s.progress <- info + } +} + +func (s *scanState) isSelectiveScan() bool { + return len(s.targets) > 0 +} + +func (s *scanState) sendWarning(msg string) { + s.sendProgress(&ProgressInfo{Warning: msg}) +} + +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(ctx).GetAll() + if err != nil { + state.sendWarning(fmt.Sprintf("getting libraries: %s", err)) + return + } + + if len(targets) > 0 { + // Selective scan: filter libraries and build targets map + state.targets = make(map[int][]string) + + for _, target := range targets { + folderPath := 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)) + } else { + // Full library scan + state.libraries = allLibs + log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries)) + } + + // Store scan type and start time + scanType := "quick" + if state.fullScan { + scanType = "full" + } + if state.isSelectiveScan() { + scanType += "-selective" + } + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType) + _ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339)) + + // if there was a full scan in progress, force a full scan + if !state.fullScan { + for _, lib := range state.libraries { + if lib.FullScanInProgress { + log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name) + state.fullScan = true + if state.isSelectiveScan() { + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full-selective") + } else { + _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full") + } + break + } + } + } + + // 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, s.cw)), + + // 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, s.cw)), + ), + + // 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), + + // Refresh artist and tags stats + s.runRefreshStats(ctx, &state), + + // Update last_scan_completed_at for all libraries + s.runUpdateLibraries(ctx, &state), + + // Optimize DB + s.runOptimize(ctx), + ) + if err != nil { + log.Error(ctx, "Scanner: Finished with error", "duration", time.Since(startTime), err) + _ = s.ds.Property(ctx).Put(consts.LastScanErrorKey, err.Error()) + state.sendError(err) + return + } + + _ = s.ds.Property(ctx).Put(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)) + } +} + +// 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 { + if lib.LastScanStartedAt.IsZero() { + // This is a new scan - mark it as started + err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan) + 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(ctx).Get(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) + } + + if len(successfulLibs) == 0 { + return fmt.Errorf("no libraries available for scanning") + } + + // Update state with only successfully initialized libraries + state.libraries = successfulLibs + return nil +} + +func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error { + return func() error { + state.sendProgress(&ProgressInfo{ForceUpdate: true}) + return s.ds.WithTx(func(tx model.DataStore) error { + if state.changesDetected.Load() { + start := time.Now() + + // 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) + } + + err := tx.GC(ctx, libraryIDs...) + if err != nil { + log.Error(ctx, "Scanner: Error running GC", err) + 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 *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(ctx).RefreshStats(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)) + + start = time.Now() + err = s.ds.Tag(ctx).UpdateCounts() + 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 *scannerImpl) runOptimize(ctx context.Context) func() error { + return func() error { + start := time.Now() + db.Optimize(ctx) + log.Debug(ctx, "Scanner: Optimized DB", "elapsed", time.Since(start)) + return nil + } +} + +func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState) func() error { + return func() error { + start := time.Now() + return s.ds.WithTx(func(tx model.DataStore) error { + for _, lib := range state.libraries { + err := tx.Library(ctx).ScanEnd(lib.ID) + if err != nil { + log.Error(ctx, "Scanner: Error updating last scan completed", "lib", lib.Name, err) + return fmt.Errorf("updating last scan completed: %w", err) + } + err = tx.Property(ctx).Put(consts.PIDTrackKey, conf.Server.PID.Track) + if err != nil { + log.Error(ctx, "Scanner: Error updating track PID conf", err) + return fmt.Errorf("updating track PID conf: %w", err) + } + err = tx.Property(ctx).Put(consts.PIDAlbumKey, conf.Server.PID.Album) + if err != nil { + log.Error(ctx, "Scanner: Error updating album PID conf", err) + return fmt.Errorf("updating album PID conf: %w", err) + } + if state.changesDetected.Load() { + log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name) + if err := tx.Library(ctx).RefreshStats(lib.ID); err != nil { + log.Error(ctx, "Scanner: Error refreshing library stats", "lib", lib.Name, err) + return fmt.Errorf("refreshing library stats: %w", 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...) + } + + 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 + } +} + +var _ scanner = (*scannerImpl)(nil) diff --git a/scanner/scanner_benchmark_test.go b/scanner/scanner_benchmark_test.go new file mode 100644 index 0000000..2b1c0a1 --- /dev/null +++ b/scanner/scanner_benchmark_test.go @@ -0,0 +1,89 @@ +package scanner_test + +import ( + "context" + "fmt" + "os" + "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" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "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" + "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"), + ) + + tmpDir := os.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "test-scanner.db?_journal_mode=WAL") + db.Init(context.Background()) + + ds := persistence.New(db.Db()) + conf.Server.DevExternalScanner = false + s := scanner.New(context.Background(), ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(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(context.Background()).Put(&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 new file mode 100644 index 0000000..e8abb7c --- /dev/null +++ b/scanner/scanner_internal_test.go @@ -0,0 +1,98 @@ +// 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 new file mode 100644 index 0000000..66db62e --- /dev/null +++ b/scanner/scanner_multilibrary_test.go @@ -0,0 +1,831 @@ +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" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "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() { + 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.DevExternalScanner = false + + 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(ctx).Put(&adminUser)).To(Succeed()) + + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(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(ctx).Put(&lib1)).To(Succeed()) + Expect(ds.Library(ctx).Put(&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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll() + 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(ctx).Get(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(ctx).Get(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(ctx).Get(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(ctx).Get(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(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(4)) + + jazzFiles, err := ds.MediaFile(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(1)) + + jazzFiles, err := ds.MediaFile(ctx).GetAll(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(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"library_id": lib1.ID}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(rockFiles).To(HaveLen(1)) + + jazzFiles, err = ds.MediaFile(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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(ctx).Get(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(ctx).DefaultGet(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(ctx).GetAll(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(ctx).Get(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(ctx).DefaultGet(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(ctx), + 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(ctx).DefaultGet(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(ctx), + 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(ctx).DefaultGet(consts.LastScanErrorKey, "") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(ContainSubstring("critical database error")) + + // Scan type should still be recorded + scanType, _ := ds.Property(ctx).DefaultGet(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(ctx).GetAll(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(ctx).Get(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(ctx).GetAll(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(ctx).DefaultGet(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(ctx).GetAll(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(ctx).DefaultGet(consts.LastScanTypeKey, "") + Expect(scanType).To(Equal("full")) + + // Start time should be recorded + startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + Expect(startTimeStr).ToNot(BeEmpty()) + + // Error should be empty (warnings don't count as scan errors) + lastError, err := ds.Property(ctx).DefaultGet(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).ToNot(BeEmpty(), "Should have warnings for temporary disk error") + + // Verify both libraries now have content (at least jazz should work) + rockFiles, err := ds.MediaFile(ctx).GetAll(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(ctx).GetAll(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(ctx).Get(lib1.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(rockLib.TotalSongs).To(Equal(2)) + + jazzLib, err := ds.Library(ctx).Get(lib2.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(jazzLib.TotalSongs).To(Equal(1)) + + // Error should be empty (successful recovery) + lastError, err := ds.Property(ctx).DefaultGet(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(ctx).DefaultGet(consts.LastScanTypeKey, "") + Expect(scanType).To(Equal("full")) + + startTimeStr, _ := ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + Expect(startTimeStr).ToNot(BeEmpty()) + _, err := time.Parse(time.RFC3339, startTimeStr) + Expect(err).ToNot(HaveOccurred()) + + lastError, err := ds.Property(ctx).DefaultGet(consts.LastScanErrorKey, "unset") + Expect(err).ToNot(HaveOccurred()) + Expect(lastError).To(BeEmpty()) + }) + }) +}) diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go new file mode 100644 index 0000000..629826d --- /dev/null +++ b/scanner/scanner_selective_test.go @@ -0,0 +1,293 @@ +package scanner_test + +import ( + "context" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/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() { + 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(ctx).Put(&adminUser)).To(Succeed()) + + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + + lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&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"}) + 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(ctx).GetAll() + 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")) + }) + }) + + 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(ctx).GetAll(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(ctx).GetAll() + 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(ctx).Get(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(ctx).Get(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(ctx).Get(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(ctx).Get(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(ctx).Get(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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll() + 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 new file mode 100644 index 0000000..8a2c6b2 --- /dev/null +++ b/scanner/scanner_suite_test.go @@ -0,0 +1,26 @@ +package scanner_test + +import ( + "context" + "testing" + + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/goleak" +) + +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"), + ) + + tests.Init(t, true) + defer db.Close(context.Background()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Scanner Suite") +} diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go new file mode 100644 index 0000000..873065a --- /dev/null +++ b/scanner/scanner_test.go @@ -0,0 +1,805 @@ +package scanner_test + +import ( + "context" + "errors" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "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" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "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}) + tmpDir := GinkgoT().TempDir() + 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(ctx), + } + 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(ctx).Put(&adminUser)).To(Succeed()) + + s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + core.NewPlaylists(ds), metrics.NewNoopInstance()) + + lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + }) + + runScanner := func(ctx context.Context, fullScan bool) error { + _, err := s.ScanAll(ctx, fullScan) + return err + } + + 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(ctx).GetAll(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(ctx).GetAll() + 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(ctx).GetAll(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)), + )) + }) + }) + When("a file was changed", func() { + It("should update the media_file", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + mf, err := ds.MediaFile(ctx).GetAll(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(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"title": "Help!"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(mf[0].Tags).To(HaveKeyWithValue(model.TagName("barcode"), []string{"123"})) + }) + + It("should update the album", func() { + Expect(runScanner(ctx, true)).To(Succeed()) + + albums, err := ds.Album(ctx).GetAll(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(ctx).GetAll(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)) + }) + }) + }) + + 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(ctx).GetAll() + 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(ctx).GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1)) + + mfs, err := ds.MediaFile(ctx).GetAll() + 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(ctx).GetAll(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() { + 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(ctx).CountAll()).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(ctx).CountAll()).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(ctx).CountAll()).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(ctx).CountAll()).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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).GetAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(model.QueryOptions{ + Filters: squirrel.Eq{"missing": false}, + })).To(Equal(int64(2))) + }) + + 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(ctx).GetAll(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(ctx).GetAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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(ctx).CountAll(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("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(ctx) + 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() { + // 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(ctx).GetAll(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(ctx).GetAll(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 + }) + }) +}) + +func createFindByPath(ctx context.Context, ds model.DataStore) func(string) (*model.MediaFile, error) { + return func(path string) (*model.MediaFile, error) { + list, err := ds.MediaFile(ctx).FindByPaths([]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 +} + +func (m *mockMediaFileRepo) GetMissingAndMatching(libId int) (model.MediaFileCursor, error) { + if m.GetMissingAndMatchingError != nil { + return nil, m.GetMissingAndMatchingError + } + return m.MediaFileRepository.GetMissingAndMatching(libId) +} + +type testArtistRepo struct { + model.ArtistRepository + callTracker *[]bool +} + +func (m *testArtistRepo) RefreshStats(allArtists bool) (int64, error) { + *m.callTracker = append(*m.callTracker, allArtists) + return m.ArtistRepository.RefreshStats(allArtists) +} diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go new file mode 100644 index 0000000..e6a694f --- /dev/null +++ b/scanner/walk_dir_tree.go @@ -0,0 +1,254 @@ +package scanner + +import ( + "context" + "io/fs" + "maps" + "path" + "slices" + "sort" + "strings" + + "github.com/navidrome/navidrome/conf" + "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{"."} + } + 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 + } + + // 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 err != nil { + log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err) + continue + } + } + log.Debug(ctx, "Scanner: Finished reading target folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load()) + }() + return results, nil +} + +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) + if err != nil { + log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err) + return nil + } + for _, c := range children { + err := walkFolder(ctx, job, c, checker, 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", folder.numPlaylists, "imagesUpdatedAt", folder.imagesUpdatedAt, + "updTime", folder.updTime, "modTime", folder.modTime, "numChildren", len(children)) + folder.path = dir + folder.elapsed.Start() + + results <- folder + + 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) + if err != nil { + log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err) + return nil, nil, err + } + + // 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) + if err != nil { + log.Warn(ctx, "Scanner: Error in Opening directory", "path", dirPath, err) + return folder, children, err + } + defer dir.Close() + dirFile, ok := dir.(fs.ReadDirFile) + if !ok { + log.Error(ctx, "Not a directory", "path", dirPath) + return folder, children, 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 isEntryIgnored(entry.Name()) { + continue + } + if ctx.Err() != nil { + return folder, children, ctx.Err() + } + isDir, err := isDirOrSymlinkToDir(job.fs, dirPath, entry) + // Skip invalid symlinks + if err != nil { + log.Warn(ctx, "Scanner: Invalid symlink", "dir", entryPath, err) + continue + } + if isDir && !isDirIgnored(entry.Name()) && isDirReadable(ctx, job.fs, entryPath) { + children = append(children, entryPath) + folder.numSubFolders++ + } else { + fileInfo, err := entry.Info() + if err != nil { + log.Warn(ctx, "Scanner: Error getting fileInfo", "name", entry.Name(), err) + return folder, children, err + } + if fileInfo.ModTime().After(folder.modTime) { + folder.modTime = fileInfo.ModTime() + } + switch { + case model.IsAudioFile(entry.Name()): + folder.audioFiles[entry.Name()] = entry + case model.IsValidPlaylist(entry.Name()): + folder.numPlaylists++ + case model.IsImageFile(entry.Name()): + folder.imageFiles[entry.Name()] = entry + folder.imagesUpdatedAt = utils.TimeNewest(folder.imagesUpdatedAt, fileInfo.ModTime(), folder.modTime) + } + } + } + return folder, children, nil +} + +// fullReadDir reads all files in the folder, skipping the ones with errors. +// It also detects when it is "stuck" with an error in the same directory over and over. +// In this case, it stops and returns whatever it was able to read until it got stuck. +// See discussion here: https://github.com/navidrome/navidrome/issues/1164#issuecomment-881922850 +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 { + break + } + log.Warn(ctx, "Skipping DirEntry", err) + if prevErrStr == err.Error() { + log.Error(ctx, "Scanner: Duplicate DirEntry failure, bailing", err) + break + } + prevErrStr = err.Error() + } + sort.Slice(allEntries, func(i, j int) bool { return allEntries[i].Name() < allEntries[j].Name() }) + return allEntries +} + +// isDirOrSymlinkToDir returns true if and only if the dirEnt represents a file +// system directory, or a symbolic link to a directory. Note that if the dirEnt +// is not a directory but is a symbolic link, this method will resolve by +// sending a request to the operating system to follow the symbolic link. +// originally copied from github.com/karrick/godirwalk, modified to use dirEntry for +// efficiency for go 1.16 and beyond +func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool, error) { + 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 { + return false, nil + } + // Does this symlink point to a directory? + fileInfo, err := fs.Stat(fsys, path.Join(baseDir, dirEnt.Name())) + if err != nil { + return false, err + } + return fileInfo.IsDir(), 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) + if err != nil { + log.Warn("Scanner: Skipping unreadable directory", "path", dirPath, err) + return false + } + err = dir.Close() + if err != nil { + log.Warn(ctx, "Scanner: Error closing directory", "path", dirPath, err) + } + return true +} + +// List of special directories to ignore +var ignoredDirs = []string{ + "$RECYCLE.BIN", + "#snapshot", + "@Recycle", + "@Recently-Snapshot", + ".streams", + "lost+found", +} + +// isDirIgnored returns true if the directory represented by dirEnt should be ignored +func isDirIgnored(name string) bool { + // allows Album folders for albums which eg start with ellipses + if strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") { + return true + } + if slices.ContainsFunc(ignoredDirs, func(s string) bool { return strings.EqualFold(s, name) }) { + return true + } + return false +} + +func isEntryIgnored(name string) bool { + return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..") +} diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go new file mode 100644 index 0000000..c9add0b --- /dev/null +++ b/scanner/walk_dir_tree_test.go @@ -0,0 +1,414 @@ +package scanner + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "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/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/sync/errgroup" +) + +var _ = Describe("walk_dir_tree", func() { + Describe("walkDirTree", func() { + var ( + fsys storage.MusicFS + job *scanJob + ctx context.Context + ) + + 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")}, + }, + } + 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 + } + + 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")) + + // Symlink specific checks + if followSymlinks { + Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1)) + } else { + Expect(folders).ToNot(HaveKey("root/e/symlink")) + } + }, + Entry("with symlinks enabled", true, 7), + Entry("with symlinks disabled", false, 6), + ) + }) + + 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"))) + }) + }) + }) + + 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() { + 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("isDirIgnored", func() { + DescribeTable("returns expected result", + func(dirName string, expected bool) { + Expect(isDirIgnored(dirName)).To(Equal(expected)) + }, + Entry("normal dir", "empty_folder", false), + Entry("hidden dir", ".hidden_folder", true), + Entry("dir starting with ellipsis", "...unhidden_folder", false), + Entry("recycle bin", "$Recycle.Bin", true), + Entry("snapshot dir", "#snapshot", true), + ) + }) + + 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{}), + ) + }) + }) +}) + +type fakeFS struct { + fstest.MapFS + failOn string + err error +} + +func (f *fakeFS) Open(name string) (fs.File, error) { + dir, err := f.MapFS.Open(name) + return &fakeDirFile{File: dir, fail: f.failOn, err: f.err}, err +} + +type fakeDirFile struct { + fs.File + entries []fs.DirEntry + pos int + fail string + err error +} + +// Only works with n == -1 +func (fd *fakeDirFile) ReadDir(int) ([]fs.DirEntry, error) { + if fd.err != nil { + return nil, fd.err + } + if fd.entries == nil { + fd.entries, _ = fd.File.(fs.ReadDirFile).ReadDir(-1) + } + var dirs []fs.DirEntry + for { + if fd.pos >= len(fd.entries) { + break + } + e := fd.entries[fd.pos] + fd.pos++ + if e.Name() == fd.fail { + return dirs, &fs.PathError{Op: "lstat", Path: e.Name(), Err: fs.ErrPermission} + } + dirs = append(dirs, e) + } + return dirs, nil +} + +func getDirEntry(baseDir, name string) os.DirEntry { + dirEntries, _ := os.ReadDir(baseDir) + for _, entry := range dirEntries { + if entry.Name() == name { + return entry + } + } + panic(fmt.Sprintf("Could not find %s in %s", name, baseDir)) +} + +// 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") +} diff --git a/scanner/watcher.go b/scanner/watcher.go new file mode 100644 index 0000000..3efebaa --- /dev/null +++ b/scanner/watcher.go @@ -0,0 +1,335 @@ +package scanner + +import ( + "context" + "fmt" + "io/fs" + "path/filepath" + "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 + 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, 1), + 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(ctx).GetAll() + 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() + + // 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) + 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 + select { + case w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}: + default: + // Channel is full, notification already pending + } + } + } +} + +// 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 { + baseDir, name := filepath.Split(path) + switch { + case model.IsAudioFile(path): + return false + case model.IsValidPlaylist(path): + return false + case model.IsImageFile(path): + return false + 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 isDirIgnored(baseDir) +} diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go new file mode 100644 index 0000000..01bfb24 --- /dev/null +++ b/scanner/watcher_test.go @@ -0,0 +1,491 @@ +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/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()) + conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests + + ctx, cancel = context.WithCancel(context.Background()) + DeferCleanup(cancel) + + lib = &model.Library{ + ID: 1, + Name: "Test Library", + Path: "/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("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", func() { + // Send notifications for different folders + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + time.Sleep(10 * time.Millisecond) + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"} + + // Wait for watcher to process and trigger scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Verify two targets + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(2)) + + // Extract folder paths + folderPaths := make(map[string]bool) + for _, target := range calls[0].Targets { + Expect(target.LibraryID).To(Equal(1)) + 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() + }, 200*time.Millisecond, 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 notification for a folder + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} + time.Sleep(10 * time.Millisecond) + // Send notification for same folder (as if file change was detected there) + // In practice, watchLibrary() would walk up from file path to folder + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} + time.Sleep(10 * time.Millisecond) + // Send another for same folder + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} + + // Wait for watcher to process and trigger scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // 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)", func() { + // Send first notification + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + + // Wait a bit less than half the watcher wait time to ensure timer doesn't fire + time.Sleep(20 * time.Millisecond) + + // No scan should have been triggered yet + Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + + // Send another notification (resets timer) + w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} + + // Wait a bit less than half the watcher wait time again + time.Sleep(20 * time.Millisecond) + + // Still no scan + Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + + // Wait for full timer to expire after last notification (plus margin) + time.Sleep(60 * time.Millisecond) + + // Now scan should have been triggered + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 100*time.Millisecond, 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() + }, 200*time.Millisecond, 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() + }, 200*time.Millisecond, 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: ""} + time.Sleep(10 * time.Millisecond) + w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} + + // Wait for scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // 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: "/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"} + time.Sleep(10 * time.Millisecond) + w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"} + + // Wait for scan + Eventually(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + + // Verify two targets for different libraries + calls := mockScanner.GetScanFoldersCalls() + Expect(calls).To(HaveLen(1)) + Expect(calls[0].Targets).To(HaveLen(2)) + + // Verify library IDs are different + 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() { + 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() { + 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() { + 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("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() { + result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3") + Expect(result).To(Equal("artist1/album1")) + }) + + It("walks up multiple levels if needed", func() { + 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() { + 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/log_adapter.go b/scheduler/log_adapter.go new file mode 100644 index 0000000..ccaab0c --- /dev/null +++ b/scheduler/log_adapter.go @@ -0,0 +1,24 @@ +package scheduler + +import ( + "github.com/navidrome/navidrome/log" +) + +type logger struct{} + +func (l *logger) Info(msg string, keysAndValues ...interface{}) { + args := []interface{}{ + "Scheduler: " + msg, + } + args = append(args, keysAndValues...) + log.Debug(args...) +} + +func (l *logger) Error(err error, msg string, keysAndValues ...interface{}) { + args := []interface{}{ + "Scheduler: " + msg, + } + args = append(args, keysAndValues...) + args = append(args, err) + log.Error(args...) +} diff --git a/scheduler/scheduler.go b/scheduler/scheduler.go new file mode 100644 index 0000000..b377e79 --- /dev/null +++ b/scheduler/scheduler.go @@ -0,0 +1,45 @@ +package scheduler + +import ( + "context" + + "github.com/navidrome/navidrome/utils/singleton" + "github.com/robfig/cron/v3" +) + +type Scheduler interface { + Run(ctx context.Context) + Add(crontab string, cmd func()) (int, error) + Remove(id int) +} + +func GetInstance() Scheduler { + return singleton.GetInstance(func() *scheduler { + c := cron.New(cron.WithLogger(&logger{})) + return &scheduler{ + c: c, + } + }) +} + +type scheduler struct { + c *cron.Cron +} + +func (s *scheduler) Run(ctx context.Context) { + s.c.Start() + <-ctx.Done() + s.c.Stop() +} + +func (s *scheduler) Add(crontab string, cmd func()) (int, error) { + entryID, err := s.c.AddFunc(crontab, cmd) + if err != nil { + return 0, err + } + return int(entryID), nil +} + +func (s *scheduler) Remove(id int) { + s.c.Remove(cron.EntryID(id)) +} diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go new file mode 100644 index 0000000..4737ae3 --- /dev/null +++ b/scheduler/scheduler_test.go @@ -0,0 +1,86 @@ +package scheduler + +import ( + "sync" + "testing" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/robfig/cron/v3" +) + +func TestScheduler(t *testing.T) { + tests.Init(t, false) + 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", func() { + wg := sync.WaitGroup{} + wg.Add(1) + + executed := false + id, err := s.Add("@every 100ms", func() { + executed = true + wg.Done() + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeZero()) + + wg.Wait() + Expect(executed).To(BeTrue()) + }) + + It("removes a job", func() { + // Use a WaitGroup to ensure the job executes once + wg := sync.WaitGroup{} + wg.Add(1) + + counter := 0 + id, err := s.Add("@every 100ms", func() { + counter++ + if counter == 1 { + wg.Done() // Signal that the job has executed once + } + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(id).ToNot(BeZero()) + + // Wait for the job to execute at least once + wg.Wait() + + // Verify job executed + Expect(counter).To(Equal(1)) + + // Remove the job + s.Remove(id) + + // Store the counter value + currentCount := counter + + // Wait some time to ensure job doesn't execute again + time.Sleep(200 * time.Millisecond) + + // Verify counter didn't increase + Expect(counter).To(Equal(currentCount)) + }) +}) diff --git a/server/auth.go b/server/auth.go new file mode 100644 index 0000000..8588549 --- /dev/null +++ b/server/auth.go @@ -0,0 +1,371 @@ +package server + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "slices" + "strings" + "time" + + "github.com/deluan/rest" + "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/model/id" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/gravatar" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + ErrNoUsers = errors.New("no users created") + ErrUnauthenticated = errors.New("request not authenticated") +) + +func login(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + username, password, err := getCredentialsFromBody(r) + if err != nil { + log.Error(r, "Parsing request body", err) + _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + + doLogin(ds, username, password, w, r) + } +} + +func doLogin(ds model.DataStore, username string, password string, w http.ResponseWriter, r *http.Request) { + 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 + } + if user == nil { + log.Warn(r, "Unsuccessful login", "username", username, "request", r.Header) + _ = rest.RespondWithError(w, http.StatusUnauthorized, "Invalid username or password") + return + } + + tokenString, err := auth.CreateToken(user) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, "Unknown error authenticating user. Please try again") + return + } + payload := buildAuthPayload(user) + payload["token"] = tokenString + _ = rest.RespondWithJSON(w, http.StatusOK, payload) +} + +func buildAuthPayload(user *model.User) map[string]interface{} { + payload := map[string]interface{}{ + "id": user.ID, + "name": user.Name, + "username": user.UserName, + "isAdmin": user.IsAdmin, + } + if conf.Server.EnableGravatar && user.Email != "" { + payload["avatar"] = gravatar.Url(user.Email, 50) + } + + bytes := make([]byte, 3) + _, err := rand.Read(bytes) + if err != nil { + log.Error("Could not create subsonic salt", "user", user.UserName, err) + return payload + } + subsonicSalt := hex.EncodeToString(bytes) + payload["subsonicSalt"] = subsonicSalt + + subsonicToken := md5.Sum([]byte(user.Password + subsonicSalt)) + payload["subsonicToken"] = hex.EncodeToString(subsonicToken[:]) + + return payload +} + +func getCredentialsFromBody(r *http.Request) (username string, password string, err error) { + data := make(map[string]string) + decoder := json.NewDecoder(r.Body) + if err = decoder.Decode(&data); err != nil { + log.Error(r, "parsing request body", err) + err = errors.New("invalid request payload") + return + } + username = data["username"] + password = data["password"] + return username, password, nil +} + +func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + username, password, err := getCredentialsFromBody(r) + if err != nil { + log.Error(r, "parsing request body", err) + _ = rest.RespondWithError(w, http.StatusUnprocessableEntity, err.Error()) + return + } + c, err := ds.User(r.Context()).CountAll() + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + if c > 0 { + _ = rest.RespondWithError(w, http.StatusForbidden, "Cannot create another first admin") + return + } + err = createAdminUser(r.Context(), ds, username, password) + if err != nil { + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + doLogin(ds, username, password, w, r) + } +} + +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(), + UserName: username, + Name: caser.String(username), + Email: "", + NewPassword: password, + IsAdmin: true, + LastLoginAt: &now, + } + err := ds.User(ctx).Put(&initialUser) + if err != nil { + log.Error(ctx, "Could not create initial user", "user", initialUser, err) + } + return nil +} + +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 + } + if err != nil { + return nil, err + } + if u.Password != password { + return nil, nil + } + err = userRepo.UpdateLastLoginAt(u.ID) + if err != nil { + 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) +} + +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 UsernameFromToken(r *http.Request) string { + token, claims, err := jwtauth.FromContext(r.Context()) + if err != nil || claims["sub"] == nil || token == nil { + return "" + } + 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 == "" { + 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.") + return "" + } + if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) { + log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr) + return "" + } + username := r.Header.Get(conf.Server.ExtAuth.UserHeader) + if username == "" { + return "" + } + log.Trace(r, "Found username in ExtAuth.UserHeader", "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 { + return conf.Server.DevAutoLoginUsername +} + +func contextWithUser(ctx context.Context, ds model.DataStore, username string) (context.Context, error) { + user, err := ds.User(ctx).FindByUsername(username) + if err == nil { + ctx = log.NewContext(ctx, "username", username) + ctx = request.WithUsername(ctx, user.UserName) + return request.WithUser(ctx, *user), nil + } + log.Error(ctx, "Authenticated username not found in DB", "username", username) + return ctx, err +} + +func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ...func(r *http.Request) string) (context.Context, error) { + var username string + for _, fn := range findUsernameFns { + username = fn(r) + if username != "" { + break + } + } + if username == "" { + return nil, ErrUnauthenticated + } + + return contextWithUser(r.Context(), ds, username) +} + +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 { + _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// 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) { + ctx := r.Context() + token, _, err := jwtauth.FromContext(ctx) + if err != nil { + next.ServeHTTP(w, r) + return + } + newTokenString, err := auth.TouchToken(token) + if err != nil { + log.Error(r, "Could not sign new token", err) + _ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated") + return + } + + w.Header().Set(consts.UIAuthorizationHeader, newTokenString) + next.ServeHTTP(w, r) + }) +} + +func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} { + username := UsernameFromConfig(r) + if username == "" { + username = UsernameFromExtAuthHeader(r) + if username == "" { + return nil + } + } + + 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() + isFirstUser := count == 0 + + newUser := model.User{ + ID: id.NewRandom(), + UserName: username, + Name: username, + Email: "", + NewPassword: consts.PasswordAutogenPrefix + id.NewRandom(), + IsAdmin: isFirstUser, // Make the first user an admin + } + err := userRepo.Put(&newUser) + if err != nil { + log.Error(r, "Could not create new user", "user", username, err) + return nil + } + 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(user.ID) + if err != nil { + log.Error(r, "Could not update LastLoginAt", "user", username, err) + return nil + } + + return buildAuthPayload(user) +} + +func validateIPAgainstList(ip string, comaSeparatedList string) bool { + if comaSeparatedList == "" || ip == "" { + 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) + } + + if ip == "" { + return false + } + + testedIP, _, err := net.ParseCIDR(fmt.Sprintf("%s/32", ip)) + if err != nil { + return false + } + + for _, cidr := range cidrs { + _, ipnet, err := net.ParseCIDR(cidr) + if err == nil && ipnet.Contains(testedIP) { + return true + } + } + + return false +} diff --git a/server/auth_test.go b/server/auth_test.go new file mode 100644 index 0000000..6332990 --- /dev/null +++ b/server/auth_test.go @@ -0,0 +1,345 @@ +package server + +import ( + "context" + "crypto/md5" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/auth" + "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" +) + +var _ = Describe("Auth", func() { + Describe("User login", func() { + var ds model.DataStore + var req *http.Request + var resp *httptest.ResponseRecorder + + BeforeEach(func() { + ds = &tests.MockDataStore{} + auth.Init(ds) + }) + + 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(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]interface{} + Expect(json.Unmarshal(resp.Body.Bytes(), &parsed)).To(BeNil()) + Expect(parsed["isAdmin"]).To(Equal(true)) + Expect(parsed["username"]).To(Equal("johndoe")) + Expect(parsed["name"]).To(Equal("Johndoe")) + Expect(parsed["id"]).ToNot(BeEmpty()) + Expect(parsed["token"]).ToNot(BeEmpty()) + }) + }) + + 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(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" + }) + + It("sets auth data if IPv4 matches whitelist", func() { + req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4)) + serveIndex(ds, fs, nil)(resp, req) + + config := extractAppConfig(resp.Body.String()) + 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)) + serveIndex(ds, fs, nil)(resp, req) + + config := extractAppConfig(resp.Body.String()) + Expect(config["auth"]).To(BeNil()) + }) + + It("sets auth data if IPv6 matches whitelist", func() { + req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv6)) + serveIndex(ds, fs, nil)(resp, req) + + config := extractAppConfig(resp.Body.String()) + 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)) + 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) + serveIndex(ds, fs, nil)(resp, req) + + config := extractAppConfig(resp.Body.String()) + parsed := config["auth"].(map[string]interface{}) + + Expect(parsed["username"]).To(Equal(newUser)) + }) + + It("sets auth data if user exists", func() { + req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIpv4)) + serveIndex(ds, fs, nil)(resp, req) + + config := extractAppConfig(resp.Body.String()) + parsed := config["auth"].(map[string]interface{}) + + Expect(parsed["id"]).To(Equal("111")) + Expect(parsed["isAdmin"]).To(BeFalse()) + Expect(parsed["name"]).To(Equal("Jane")) + Expect(parsed["username"]).To(Equal("janedoe")) + Expect(parsed["subsonicSalt"]).ToNot(BeEmpty()) + Expect(parsed["subsonicToken"]).ToNot(BeEmpty()) + salt := parsed["subsonicSalt"].(string) + token := fmt.Sprintf("%x", md5.Sum([]byte("abc123"+salt))) + Expect(parsed["subsonicToken"]).To(Equal(token)) + + // 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]interface{}) + + Expect(parsed["id"]).To(Equal("111")) + }) + }) + + Describe("login", func() { + BeforeEach(func() { + req = httptest.NewRequest("POST", "/login", strings.NewReader(`{"username":"janedoe", "password":"abc123"}`)) + resp = httptest.NewRecorder() + }) + + It("fails if user does not exist", func() { + login(ds)(resp, req) + Expect(resp.Code).To(Equal(http.StatusUnauthorized)) + }) + + It("logs in successfully if user exists", func() { + usr := ds.User(context.Background()) + _ = usr.Put(&model.User{ID: "111", UserName: "janedoe", NewPassword: "abc123", Name: "Jane", IsAdmin: false}) + + login(ds)(resp, req) + Expect(resp.Code).To(Equal(http.StatusOK)) + + 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")) + Expect(parsed["name"]).To(Equal("Jane")) + Expect(parsed["id"]).ToNot(BeEmpty()) + Expect(parsed["token"]).ToNot(BeEmpty()) + }) + }) + }) + + 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()) + }) + }) + + Describe("validateIPAgainstList", func() { + Context("when provided with empty inputs", func() { + It("should return false", func() { + Expect(validateIPAgainstList("", "")).To(BeFalse()) + Expect(validateIPAgainstList("192.168.1.1", "")).To(BeFalse()) + Expect(validateIPAgainstList("", "192.168.0.0/16")).To(BeFalse()) + }) + }) + + Context("when provided with invalid IP inputs", func() { + It("should return false", func() { + Expect(validateIPAgainstList("invalidIP", "192.168.0.0/16")).To(BeFalse()) + }) + }) + + Context("when provided with valid inputs", func() { + It("should return true when IP is in the list", func() { + Expect(validateIPAgainstList("192.168.1.1", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue()) + Expect(validateIPAgainstList("10.0.0.1", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue()) + }) + + It("should return false when IP is not in the list", func() { + Expect(validateIPAgainstList("172.16.0.1", "192.168.0.0/16,10.0.0.0/8")).To(BeFalse()) + }) + }) + + Context("when provided with invalid CIDR notation in the list", func() { + It("should ignore invalid CIDR and return the correct result", func() { + Expect(validateIPAgainstList("192.168.1.1", "192.168.0.0/16,invalidCIDR")).To(BeTrue()) + Expect(validateIPAgainstList("10.0.0.1", "invalidCIDR,10.0.0.0/8")).To(BeTrue()) + Expect(validateIPAgainstList("172.16.0.1", "192.168.0.0/16,invalidCIDR")).To(BeFalse()) + }) + }) + + Context("when provided with IP:port format", func() { + It("should handle IP:port format correctly", func() { + Expect(validateIPAgainstList("192.168.1.1:8080", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue()) + Expect(validateIPAgainstList("10.0.0.1:1234", "192.168.0.0/16,10.0.0.0/8")).To(BeTrue()) + Expect(validateIPAgainstList("172.16.0.1:9999", "192.168.0.0/16,10.0.0.0/8")).To(BeFalse()) + }) + }) + }) + + 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(context.Background()).FindByUsername("firstuser") + Expect(err).To(BeNil()) + Expect(u.IsAdmin).To(BeTrue()) + }) + + It("does not make subsequent users admins", func() { + // Create the first user + _ = ds.User(context.Background()).Put(&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(context.Background()).FindByUsername("seconduser") + Expect(err).To(BeNil()) + Expect(u.IsAdmin).To(BeFalse()) + }) + }) +}) diff --git a/server/backgrounds/handler.go b/server/backgrounds/handler.go new file mode 100644 index 0000000..61b7d48 --- /dev/null +++ b/server/backgrounds/handler.go @@ -0,0 +1,140 @@ +package backgrounds + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/random" + "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 +} + +func NewHandler() *Handler { + h := &Handler{} + h.httpClient = cache.NewHTTPClient(&http.Client{Timeout: 5 * time.Second}, imageListTTL) + h.cache = cache.NewFileCache(imageCacheDir, imageCacheSize, imageCacheDir, imageCacheMaxItems, h.serveImage) + go func() { + _, _ = h.getImageList(log.NewContext(context.Background())) + }() + return h +} + +type cacheKey string + +func (k cacheKey) Key() string { + return string(k) +} + +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 := http.Client{Timeout: imageRequestTimeout} + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageURL(image), nil) + resp, err := c.Do(req) //nolint:bodyclose // No need to close resp.Body, it will be closed via the CachedStream wrapper + if errors.Is(err, context.DeadlineExceeded) { + defaultImage, _ := base64.StdEncoding.DecodeString(consts.DefaultUILoginBackgroundOffline) + return strings.NewReader(string(defaultImage)), nil + } + if err != nil { + return nil, fmt.Errorf("could not get background image from hosting service: %w", err) + } + if resp.StatusCode != http.StatusOK { + 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 +} + +func (h *Handler) getRandomImage(ctx context.Context) (string, error) { + list, err := h.getImageList(ctx) + if err != nil { + return "", err + } + if len(list) == 0 { + return "", errors.New("no images available") + } + rnd := random.Int64N(len(list)) + return list[rnd], nil +} + +func (h *Handler) getImageList(ctx context.Context) ([]string, error) { + start := time.Now() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, imageListURL, nil) + resp, err := h.httpClient.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 +} + +func imageURL(imageName string) string { + // Discard extension + parts := strings.Split(imageName, ".") + if len(parts) > 1 { + imageName = parts[0] + } + return fmt.Sprintf(imageHostingUrl, imageName) +} diff --git a/server/events/events.go b/server/events/events.go new file mode 100644 index 0000000..ff0a8a4 --- /dev/null +++ b/server/events/events.go @@ -0,0 +1,89 @@ +package events + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "time" + "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 +} + +type baseEvent struct{} + +func (e *baseEvent) Name(evt Event) string { + str := strings.TrimPrefix(reflect.TypeOf(evt).String(), "*events.") + return str[:0] + string(unicode.ToLower(rune(str[0]))) + str[1:] +} + +func (e *baseEvent) Data(evt Event) string { + data, _ := json.Marshal(evt) + return string(data) +} + +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"` +} + +type KeepAlive struct { + baseEvent + TS int64 `json:"ts"` +} + +type ServerStart struct { + baseEvent + StartTime time.Time `json:"startTime"` + Version string `json:"version"` +} + +const Any = "*" + +type RefreshResource struct { + baseEvent + 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) + } + if len(ids) == 0 { + rr.resources[resource] = append(rr.resources[resource], Any) + } + rr.resources[resource] = append(rr.resources[resource], ids...) + return rr +} + +func (rr *RefreshResource) Data(evt Event) string { + if rr.resources == nil { + return `{"*":"*"}` + } + r := evt.(*RefreshResource) + data, _ := json.Marshal(r.resources) + return string(data) +} diff --git a/server/events/events_suite_test.go b/server/events/events_suite_test.go new file mode 100644 index 0000000..ecc186c --- /dev/null +++ b/server/events/events_suite_test.go @@ -0,0 +1,17 @@ +package events + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestEvents(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Events Suite") +} diff --git a/server/events/events_test.go b/server/events/events_test.go new file mode 100644 index 0000000..abfab9b --- /dev/null +++ b/server/events/events_test.go @@ -0,0 +1,46 @@ +package events + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Events", func() { + Describe("Event", func() { + type TestEvent struct { + baseEvent + Test string + } + + It("marshals Event to JSON", func() { + testEvent := TestEvent{Test: "some data"} + data := testEvent.Data(&testEvent) + Expect(data).To(Equal(`{"Test":"some data"}`)) + name := testEvent.Name(&testEvent) + Expect(name).To(Equal("testEvent")) + }) + }) + + Describe("RefreshResource", func() { + var rr *RefreshResource + BeforeEach(func() { + rr = &RefreshResource{} + }) + + It("should render to full refresh if event is empty", func() { + data := rr.Data(rr) + Expect(data).To(Equal(`{"*":"*"}`)) + }) + It("should group resources based on name", func() { + rr.With("album", "al-1").With("song", "sg-1").With("artist", "ar-1") + rr.With("album", "al-2", "al-3").With("song", "sg-2").With("artist", "ar-2") + data := rr.Data(rr) + Expect(data).To(Equal(`{"album":["al-1","al-2","al-3"],"artist":["ar-1","ar-2"],"song":["sg-1","sg-2"]}`)) + }) + It("should send a * when no ids are specified", func() { + rr.With("album") + data := rr.Data(rr) + Expect(data).To(Equal(`{"album":["*"]}`)) + }) + }) +}) diff --git a/server/events/sse.go b/server/events/sse.go new file mode 100644 index 0000000..54a6029 --- /dev/null +++ b/server/events/sse.go @@ -0,0 +1,291 @@ +// Package events based on https://thoughtbot.com/blog/writing-a-server-sent-events-server-in-go +package events + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "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" +) + +type Broker interface { + http.Handler + SendMessage(ctx context.Context, event Event) + SendBroadcastMessage(ctx context.Context, event Event) +} + +const ( + keepAliveFrequency = 15 * time.Second + writeTimeOut = 5 * time.Second + bufferSize = 1 +) + +type ( + message struct { + id uint64 + event string + data string + senderCtx context.Context + } + messageChan chan message + clientsChan chan client + client struct { + id string + address string + username string + userAgent string + clientUniqueId string + displayString string + msgC chan message + } +) + +func (c client) String() string { + return c.displayString +} + +type broker struct { + // Events are pushed to this channel by the main events-gathering routine + publish messageChan + + // New client connections + subscribing clientsChan + + // Closed client connections + unsubscribing clientsChan +} + +func GetBroker() Broker { + return singleton.GetInstance(func() *broker { + // Instantiate a broker + broker := &broker{ + publish: make(messageChan, 2), + subscribing: make(clientsChan, 1), + unsubscribing: make(clientsChan, 1), + } + + // Set it running - listening and broadcasting events + go broker.listen() + return 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) + b.publish <- msg +} + +func (b *broker) prepareMessage(ctx context.Context, event Event) message { + msg := message{} + msg.data = event.Data(event) + msg.event = event.Name(event) + msg.senderCtx = ctx + return msg +} + +// 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. +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) + if err != nil { + return err + } + + // If the writer is a http.Flusher, flush the data immediately. + if flusher, ok := w.(http.Flusher); ok && flusher != nil { + flusher.Flush() + } + return nil +} + +func setWriteTimeout(rw io.Writer, timeout time.Duration) error { + for { + switch t := rw.(type) { + case interface{ SetWriteDeadline(time.Time) error }: + return t.SetWriteDeadline(time.Now().Add(timeout)) + case interface{ Unwrap() http.ResponseWriter }: + rw = t.Unwrap() + default: + return fmt.Errorf("%T - %w", rw, http.ErrNotSupported) + } + } +} + +func (b *broker) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _ := request.UserFrom(ctx) + + // Make sure that the writer supports flushing. + _, ok := w.(http.Flusher) + if !ok { + log.Error(r, "Streaming unsupported! Events cannot be sent to this client", "address", r.RemoteAddr, + "userAgent", r.UserAgent(), "user", user.UserName) + http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("Connection", "keep-alive") + // Tells Nginx to not buffer this response. See https://stackoverflow.com/a/33414096 + w.Header().Set("X-Accel-Buffering", "no") + + // Each connection registers its own message channel with the Broker's connections registry + c := b.subscribe(r) + defer b.unsubscribe(c) + log.Debug(ctx, "Started new EventStream connection", "client", c.String()) + + for event := range pl.ReadOrDone(ctx, c.msgC) { + log.Trace(ctx, "Sending event to client", "event", event, "client", c.String()) + err := writeEvent(ctx, w, event, writeTimeOut) + if err != nil { + log.Debug(ctx, "Error sending event to client. Closing connection", "event", event, "client", c.String(), err) + return + } + } + log.Trace(ctx, "Client EventStream connection closed", "client", c.String()) +} + +func (b *broker) subscribe(r *http.Request) client { + ctx := r.Context() + user, _ := request.UserFrom(ctx) + clientUniqueId, _ := request.ClientUniqueIdFrom(ctx) + c := client{ + id: id.NewRandom(), + 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 + b.subscribing <- c + return c +} + +func (b *broker) unsubscribe(c client) { + b.unsubscribing <- c +} + +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 + } + if c.clientUniqueId == clientUniqueId { + return false + } + if username, ok := request.UsernameFrom(msg.senderCtx); ok { + return username == c.username + } + return true +} + +func (b *broker) listen() { + keepAlive := time.NewTicker(keepAliveFrequency) + defer keepAlive.Stop() + + clients := map[client]struct{}{} + var eventId uint64 + + getNextEventId := func() uint64 { + eventId++ + return eventId + } + + for { + select { + case c := <-b.subscribing: + // A new client has connected. + // Register their message channel + clients[c] = struct{}{} + log.Debug("Client added to EventStream broker", "numActiveClients", len(clients), "newClient", c.String()) + + // Send a serverStart event to new client + msg := b.prepareMessage(context.Background(), + &ServerStart{StartTime: consts.ServerStart, Version: consts.Version}) + sendOrDrop(c, msg) + + case c := <-b.unsubscribing: + // A client has detached, and we want to + // stop sending them messages. + close(c.msgC) + delete(clients, c) + log.Debug("Removed client from EventStream broker", "numActiveClients", len(clients), "client", c.String()) + + case msg := <-b.publish: + msg.id = getNextEventId() + log.Trace("Got new published event", "event", msg) + // We got a new event from the outside! + // Send event to all connected clients + for c := range clients { + if b.shouldSend(msg, c) { + log.Trace("Putting event on client's queue", "client", c.String(), "event", msg) + sendOrDrop(c, msg) + } + } + + case ts := <-keepAlive.C: + // Send a keep alive message every 15 seconds to all connected clients + if len(clients) == 0 { + continue + } + msg := b.prepareMessage(context.Background(), &KeepAlive{TS: ts.Unix()}) + msg.id = getNextEventId() + for c := range clients { + log.Trace("Putting a keepalive event on client's queue", "client", c.String(), "event", msg) + sendOrDrop(c, msg) + } + } + } +} + +func sendOrDrop(client client, msg message) { + select { + case client.msgC <- msg: + default: + if log.IsGreaterOrEqualTo(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/events/sse_test.go b/server/events/sse_test.go new file mode 100644 index 0000000..e6a44ca --- /dev/null +++ b/server/events/sse_test.go @@ -0,0 +1,61 @@ +package events + +import ( + "context" + + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Broker", func() { + var b broker + + BeforeEach(func() { + b = broker{} + }) + + Describe("shouldSend", func() { + var c client + var ctx context.Context + BeforeEach(func() { + ctx = context.Background() + c = client{ + clientUniqueId: "1111", + username: "janedoe", + } + }) + Context("request has clientUniqueId", func() { + It("sends message for same username, different clientUniqueId", func() { + ctx = request.WithClientUniqueId(ctx, "2222") + ctx = request.WithUsername(ctx, "janedoe") + m := message{senderCtx: ctx} + Expect(b.shouldSend(m, c)).To(BeTrue()) + }) + It("does not send message for same username, same clientUniqueId", func() { + ctx = request.WithClientUniqueId(ctx, "1111") + ctx = request.WithUsername(ctx, "janedoe") + m := message{senderCtx: ctx} + Expect(b.shouldSend(m, c)).To(BeFalse()) + }) + It("does not send message for different username", func() { + ctx = request.WithClientUniqueId(ctx, "3333") + ctx = request.WithUsername(ctx, "johndoe") + m := message{senderCtx: ctx} + Expect(b.shouldSend(m, c)).To(BeFalse()) + }) + }) + Context("request does not have clientUniqueId", func() { + It("sends message for same username", func() { + ctx = request.WithUsername(ctx, "janedoe") + m := message{senderCtx: ctx} + Expect(b.shouldSend(m, c)).To(BeTrue()) + }) + It("sends message for different username", func() { + ctx = request.WithUsername(ctx, "johndoe") + m := message{senderCtx: ctx} + Expect(b.shouldSend(m, c)).To(BeTrue()) + }) + }) + }) +}) diff --git a/server/initial_setup.go b/server/initial_setup.go new file mode 100644 index 0000000..ebfdad4 --- /dev/null +++ b/server/initial_setup.go @@ -0,0 +1,101 @@ +package server + +import ( + "context" + "fmt" + "time" + + "github.com/Masterminds/squirrel" + "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() + _ = ds.WithTx(func(tx model.DataStore) error { + if err := tx.Library(ctx).StoreMusicFolder(); err != nil { + return err + } + + properties := tx.Property(ctx) + _, err := properties.Get(consts.InitialSetupFlagKey) + if err == nil { + return nil + } + log.Info("Running initial setup") + if conf.Server.DevAutoCreateAdminPassword != "" { + if err = createInitialAdminUser(tx, conf.Server.DevAutoCreateAdminPassword); err != nil { + return err + } + } + + err = properties.Put(consts.InitialSetupFlagKey, time.Now().String()) + return err + }, "initial setup") +} + +// If the Dev Admin user is not present, create it +func createInitialAdminUser(ds model.DataStore, initialPassword string) error { + users := ds.User(context.TODO()) + c, err := users.CountAll(model.QueryOptions{Filters: squirrel.Eq{"user_name": consts.DevInitialUserName}}) + if err != nil { + panic(fmt.Sprintf("Could not access User table: %s", err)) + } + if c == 0 { + newID := id.NewRandom() + log.Warn("Creating initial admin user. This should only be used for development purposes!!", + "user", consts.DevInitialUserName, "password", initialPassword, "id", newID) + initialUser := model.User{ + ID: newID, + UserName: consts.DevInitialUserName, + Name: consts.DevInitialName, + Email: "", + NewPassword: initialPassword, + IsAdmin: true, + } + err := users.Put(&initialUser) + if err != nil { + log.Error("Could not create initial admin user", "user", initialUser, err) + } + } + return err +} + +func checkFFmpegInstallation() { + f := ffmpeg.New() + _, err := f.CmdPath() + if err == nil { + return + } + log.Warn("Unable to find ffmpeg. Transcoding will fail if used", err) + if conf.Server.Scanner.Extractor == "ffmpeg" { + log.Warn("ffmpeg cannot be used for metadata extraction. Falling back to taglib") + conf.Server.Scanner.Extractor = "taglib" + } +} + +func checkExternalCredentials() { + if conf.Server.EnableExternalServices { + if !conf.Server.LastFM.Enabled { + log.Info("Last.fm integration is DISABLED") + } else { + log.Debug("Last.fm integration is ENABLED") + } + + if !conf.Server.ListenBrainz.Enabled { + log.Info("ListenBrainz integration is DISABLED") + } 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 new file mode 100644 index 0000000..982046f --- /dev/null +++ b/server/initial_setup_test.go @@ -0,0 +1,36 @@ +package server + +import ( + "context" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("initial_setup", func() { + var ds model.DataStore + + BeforeEach(func() { + ds = &tests.MockDataStore{} + }) + + Describe("createInitialAdminUser", func() { + It("creates a new admin user with specified password if User table is empty", func() { + 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(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/middlewares.go b/server/middlewares.go new file mode 100644 index 0000000..21f8979 --- /dev/null +++ b/server/middlewares.go @@ -0,0 +1,329 @@ +package server + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "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" + "github.com/unrolled/secure" +) + +func requestLogger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + + start := time.Now() + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + next.ServeHTTP(ww, r) + status := ww.Status() + + message := fmt.Sprintf("HTTP: %s %s://%s%s", r.Method, scheme, r.Host, r.RequestURI) + logArgs := []interface{}{ + r.Context(), + message, + "remoteAddr", r.RemoteAddr, + "elapsedTime", time.Since(start), + "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) { + logArgs = append(logArgs, "userAgent", r.UserAgent()) + } + + switch { + case status >= 500: + log.Error(logArgs...) + case status >= 400: + log.Warn(logArgs...) + default: + log.Debug(logArgs...) + } + }) +} + +func loggerInjector(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = log.NewContext(r.Context(), "requestId", middleware.GetReqID(ctx)) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func robotsTXT(fs fs.FS) func(http.Handler) http.Handler { + return func(next 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) + } else { + next.ServeHTTP(w, r) + } + }) + } +} + +func corsHandler() func(http.Handler) http.Handler { + return cors.Handler(cors.Options{ + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{ + http.MethodHead, + http.MethodGet, + http.MethodPost, + http.MethodPut, + http.MethodPatch, + http.MethodDelete, + }, + AllowedHeaders: []string{"*"}, + AllowCredentials: false, + ExposedHeaders: []string{"x-content-duration", "x-total-count", "x-nd-authorization"}, + }) +} + +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.HTTPSecurityHeaders.CustomFrameOptionsValue, + //ContentSecurityPolicy: "script-src 'self' 'unsafe-inline'", + }) + return sec.Handler +} + +func compressMiddleware() func(http.Handler) http.Handler { + return middleware.Compress( + 5, + "application/xml", + "application/json", + "application/javascript", + "text/html", + "text/plain", + "text/css", + "text/javascript", + "text/event-stream", + ) +} + +// clientUniqueIDMiddleware is a middleware that sets a unique client ID as a cookie if it's provided in the request header. +// If the unique client ID is not in the header but present as a cookie, it adds the ID to the request context. +func clientUniqueIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + clientUniqueId := r.Header.Get(consts.UIClientUniqueIDHeader) + + // If clientUniqueId is found in the header, set it as a cookie + if clientUniqueId != "" { + c := &http.Cookie{ + Name: consts.UIClientUniqueIDHeader, + Value: clientUniqueId, + MaxAge: consts.CookieExpiry, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + Path: cmp.Or(conf.Server.BasePath, "/"), + } + http.SetCookie(w, c) + } else { + // If clientUniqueId is not found in the header, check if it's present as a cookie + c, err := r.Cookie(consts.UIClientUniqueIDHeader) + if !errors.Is(err, http.ErrNoCookie) { + clientUniqueId = c.Value + } + } + + // If a valid clientUniqueId is found, add it to the request context + if clientUniqueId != "" { + ctx = request.WithClientUniqueId(ctx, clientUniqueId) + r = r.WithContext(ctx) + } + + // Call the next middleware or handler in the chain + next.ServeHTTP(w, r) + }) +} + +// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's +// context if navidrome is behind a trusted reverse proxy. +func realIPMiddleware(next http.Handler) http.Handler { + if conf.Server.ExtAuth.TrustedSources != "" { + return chi.Chain( + reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }), + middleware.RealIP, + ).Handler(next) + } + + // The middleware is applied without a trusted reverse proxy to support other use-cases such as multiple clients + // behind a caching proxy. In this case, navidrome only uses the request's RemoteAddr for logging, so the security + // impact of reading the headers from untrusted sources is limited. + return middleware.RealIP(next) +} + +// 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 + // 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 != "" { + r.Host = rHost + r.URL.Scheme = rScheme + } + + // 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 http.HandlerFunc(fn) +} + +// Define constants for the X-Forwarded-* header keys. +var ( + xForwardedHost = http.CanonicalHeaderKey("X-Forwarded-Host") + xForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto") + xForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") +) + +// 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 + + // 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( + r.Header.Get(xForwardedProto), + r.Header.Get(xForwardedScheme), + r.URL.Scheme, + protocol, + ) + + // 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) + } + + // Return the scheme and host of the server handling the request. + return scheme, host +} + +// URLParamsMiddleware is a middleware function that decodes the query string of +// the incoming HTTP request, adds the URL parameters from the routing context, +// and re-encodes the modified query string. +func URLParamsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Retrieve the routing context from the request context. + ctx := chi.RouteContext(r.Context()) + + // Parse the existing query string into a URL values map. + params, _ := url.ParseQuery(r.URL.RawQuery) + + // Loop through each URL parameter in the routing context. + for i, key := range ctx.URLParams.Keys { + // Skip any wildcard URL parameter keys. + if strings.Contains(key, "*") { + continue + } + + // Add the URL parameter key-value pair to the URL values map. + params.Add(":"+key, ctx.URLParams.Values[i]) + } + + // Re-encode the URL values map as a query string and replace the + // existing query string in the request. + r.URL.RawQuery = params.Encode() + + // Call the next handler in the chain with the modified request and response. + 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(ctx).UpdateLastAccessAt(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 new file mode 100644 index 0000000..5cecba7 --- /dev/null +++ b/server/middlewares_test.go @@ -0,0 +1,409 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "time" + + "github.com/go-chi/chi/v5" + "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/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("middlewares", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + Describe("robotsTXT", func() { + var nextCalled bool + next := func(w http.ResponseWriter, r *http.Request) { + nextCalled = true + } + BeforeEach(func() { + nextCalled = false + }) + + It("returns the robot.txt when requested from root", func() { + r := httptest.NewRequest("GET", "/robots.txt", nil) + w := httptest.NewRecorder() + + robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r) + + Expect(nextCalled).To(BeFalse()) + Expect(w.Body.String()).To(HavePrefix("User-agent:")) + }) + + It("allows prefixes", func() { + r := httptest.NewRequest("GET", "/app/robots.txt", nil) + w := httptest.NewRecorder() + + robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r) + + Expect(nextCalled).To(BeFalse()) + Expect(w.Body.String()).To(HavePrefix("User-agent:")) + }) + + It("passes through requests for other files", func() { + r := httptest.NewRequest("GET", "/this_is_not_a_robots.txt_file", nil) + w := httptest.NewRecorder() + + robotsTXT(os.DirFS("tests/fixtures"))(http.HandlerFunc(next)).ServeHTTP(w, r) + + Expect(nextCalled).To(BeTrue()) + }) + }) + + Describe("serverAddressMiddleware", func() { + var ( + nextHandler http.Handler + middleware http.Handler + recorder *httptest.ResponseRecorder + req *http.Request + ) + + BeforeEach(func() { + nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + middleware = serverAddressMiddleware(nextHandler) + recorder = httptest.NewRecorder() + }) + + Context("with no X-Forwarded headers", func() { + BeforeEach(func() { + req, _ = http.NewRequest("GET", "http://example.com", nil) + }) + + It("should not modify the request", func() { + middleware.ServeHTTP(recorder, req) + Expect(req.Host).To(Equal("example.com")) + Expect(req.URL.Scheme).To(Equal("http")) + }) + }) + + Context("with X-Forwarded-Host header", func() { + BeforeEach(func() { + req, _ = http.NewRequest("GET", "http://example.com", nil) + req.Header.Set("X-Forwarded-Host", "forwarded.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) + req.Header.Set("X-Forwarded-Proto", "https") + }) + + It("should modify the request with the X-Forwarded-Proto header value", func() { + middleware.ServeHTTP(recorder, req) + Expect(req.Host).To(Equal("example.com")) + Expect(req.URL.Scheme).To(Equal("https")) + }) + }) + + Context("with X-Forwarded-Scheme header", func() { + BeforeEach(func() { + req, _ = http.NewRequest("GET", "http://example.com", nil) + req.Header.Set("X-Forwarded-Scheme", "https") + }) + + It("should modify the request with the X-Forwarded-Scheme header value", func() { + middleware.ServeHTTP(recorder, req) + Expect(req.Host).To(Equal("example.com")) + Expect(req.URL.Scheme).To(Equal("https")) + }) + }) + + Context("with multiple X-Forwarded headers", func() { + BeforeEach(func() { + req, _ = http.NewRequest("GET", "http://example.com", nil) + req.Header.Set("X-Forwarded-Host", "forwarded.example.com") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Scheme", "http") + }) + + It("should modify the request with the first non-empty X-Forwarded header value", func() { + middleware.ServeHTTP(recorder, req) + Expect(req.Host).To(Equal("forwarded.example.com")) + Expect(req.URL.Scheme).To(Equal("https")) + }) + }) + + Context("with multiple values in X-Forwarded-Host header", func() { + BeforeEach(func() { + req, _ = http.NewRequest("GET", "http://example.com", nil) + req.Header.Set("X-Forwarded-Host", "forwarded1.example.com, forwarded2.example.com") + }) + + It("should modify the request with the first value in X-Forwarded-Host header", func() { + middleware.ServeHTTP(recorder, req) + Expect(req.Host).To(Equal("forwarded1.example.com")) + Expect(req.URL.Scheme).To(Equal("http")) + }) + }) + }) + + Describe("clientUniqueIDMiddleware", func() { + var ( + nextHandler http.Handler + middleware http.Handler + req *http.Request + nextReq *http.Request + rec *httptest.ResponseRecorder + ) + + BeforeEach(func() { + nextHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + nextReq = r + }) + middleware = clientUniqueIDMiddleware(nextHandler) + req, _ = http.NewRequest(http.MethodGet, "/", nil) + rec = httptest.NewRecorder() + }) + + Context("when the request header has the unique client ID", func() { + BeforeEach(func() { + req.Header.Set(consts.UIClientUniqueIDHeader, "123456") + conf.Server.BasePath = "/music" + }) + + It("sets the unique client ID as a cookie and adds it to the request context", func() { + middleware.ServeHTTP(rec, req) + + Expect(rec.Result().Cookies()).To(HaveLen(1)) + Expect(rec.Result().Cookies()[0].Name).To(Equal(consts.UIClientUniqueIDHeader)) + Expect(rec.Result().Cookies()[0].Value).To(Equal("123456")) + Expect(rec.Result().Cookies()[0].MaxAge).To(Equal(consts.CookieExpiry)) + Expect(rec.Result().Cookies()[0].HttpOnly).To(BeTrue()) + Expect(rec.Result().Cookies()[0].Secure).To(BeTrue()) + Expect(rec.Result().Cookies()[0].SameSite).To(Equal(http.SameSiteStrictMode)) + Expect(rec.Result().Cookies()[0].Path).To(Equal("/music")) + clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context()) + Expect(clientUniqueId).To(Equal("123456")) + }) + }) + + Context("when the request header does not have the unique client ID", func() { + Context("when the request has the unique client ID in a cookie", func() { + BeforeEach(func() { + req.AddCookie(&http.Cookie{ + Name: consts.UIClientUniqueIDHeader, + Value: "123456", + }) + }) + + It("adds the unique client ID to the request context", func() { + middleware.ServeHTTP(rec, req) + + Expect(rec.Result().Cookies()).To(HaveLen(0)) + + clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context()) + Expect(clientUniqueId).To(Equal("123456")) + }) + }) + + Context("when the request does not have the unique client ID in a cookie", func() { + It("does not add the unique client ID to the request context", func() { + middleware.ServeHTTP(rec, req) + + Expect(rec.Result().Cookies()).To(HaveLen(0)) + + clientUniqueId, _ := request.ClientUniqueIdFrom(nextReq.Context()) + Expect(clientUniqueId).To(BeEmpty()) + }) + }) + }) + }) + + Describe("URLParamsMiddleware", func() { + var ( + router *chi.Mux + middleware http.Handler + recorder *httptest.ResponseRecorder + testHandler http.HandlerFunc + ) + + BeforeEach(func() { + router = chi.NewRouter() + recorder = httptest.NewRecorder() + testHandler = func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("OK")) + } + }) + + Context("when request has no query parameters", func() { + It("adds URL parameters to the request", func() { + middleware = URLParamsMiddleware(testHandler) + router.Mount("/", middleware) + + req, _ := http.NewRequest("GET", "/?user=1", nil) + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusOK)) + Expect(recorder.Body.String()).To(Equal("OK")) + Expect(req.URL.RawQuery).To(ContainSubstring("user=1")) + }) + }) + + Context("when request has query parameters", func() { + It("merges URL parameters and query parameters", func() { + router.Route("/{key}", func(r chi.Router) { + r.Use(URLParamsMiddleware) + r.Get("/", testHandler) + }) + + req, _ := http.NewRequest("GET", "/test?key=value", nil) + router.ServeHTTP(recorder, req) + Expect(recorder.Code).To(Equal(http.StatusOK)) + Expect(recorder.Body.String()).To(Equal("OK")) + Expect(req.URL.RawQuery).To(ContainSubstring("key=value")) + Expect(req.URL.RawQuery).To(ContainSubstring("%3Akey=test")) + }) + }) + + Context("when URL parameter has wildcard key", func() { + It("does not include wildcard key in query parameters", func() { + router.Route("/{t*}", func(r chi.Router) { + r.Use(URLParamsMiddleware) + r.Get("/", testHandler) + }) + + req, _ := http.NewRequest("GET", "/test?key=value", nil) + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusOK)) + Expect(recorder.Body.String()).To(Equal("OK")) + Expect(req.URL.RawQuery).To(ContainSubstring("key=value")) + }) + }) + + Context("when URL parameters require encoding", func() { + It("encodes URL parameters correctly", func() { + router.Route("/{key}", func(r chi.Router) { + r.Use(URLParamsMiddleware) + r.Get("/", testHandler) + }) + + req, _ := http.NewRequest("GET", "/test with space?key=another value", nil) + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusOK)) + Expect(recorder.Body.String()).To(Equal("OK")) + queryValues, _ := url.ParseQuery(req.URL.RawQuery) + Expect(queryValues.Get(":key")).To(Equal("test with space")) + Expect(queryValues.Get("key")).To(Equal("another value")) + }) + }) + + Context("when there are multiple URL parameters", func() { + It("includes all URL parameters in the query string", func() { + router.Route("/{key}/{value}", func(r chi.Router) { + r.Use(URLParamsMiddleware) + r.Get("/", testHandler) + }) + + req, _ := http.NewRequest("GET", "/test/value?key=other_value", nil) + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusOK)) + Expect(recorder.Body.String()).To(Equal("OK")) + + queryValues, _ := url.ParseQuery(req.URL.RawQuery) + Expect(queryValues.Get(":key")).To(Equal("test")) + Expect(queryValues.Get(":value")).To(Equal("value")) + Expect(queryValues.Get("key")).To(Equal("other_value")) + }) + }) + }) + + 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(ctx).Put(&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("johndoe") + Expect(*user.LastAccessAt).To(BeTemporally(">", lastAccessTime, time.Second)) + }) + + It("skip fast successive requests", func() { + // First request + callMiddleware(req) + user, _ := ds.MockedUser.FindByUsername("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("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("johndoe") + Expect(usr.LastAccessAt).To(Equal(&lastAccessTime)) + }) + }) + }) +}) diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go new file mode 100644 index 0000000..9a86a9a --- /dev/null +++ b/server/nativeapi/config.go @@ -0,0 +1,132 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "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", "Spotify.Secret") +var sensitiveFieldsPartialMask = []string{ + "LastFM.ApiKey", + "LastFM.Secret", + "Prometheus.MetricsPath", + "Spotify.ID", + "Spotify.Secret", + "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]interface{} `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 + for _, field := range sensitiveFieldsFullMask { + if field == 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]interface{}, prefix string) { + for key, value := range config { + fullKey := key + if prefix != "" { + fullKey = prefix + "." + key + } + + switch v := value.(type) { + case map[string]interface{}: + // 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]interface{} + 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 new file mode 100644 index 0000000..d9c7229 --- /dev/null +++ b/server/nativeapi/config_test.go @@ -0,0 +1,227 @@ +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" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Config API", func() { + var ds model.DataStore + var router http.Handler + var adminUser, regularUser model.User + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevUIShowConfig = true // Enable config endpoint for tests + ds = &tests.MockDataStore{} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), 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(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®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.Spotify.Secret = "spotifysecret456" + 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]interface{}) + Expect(ok).To(BeTrue()) + Expect(lastfm["ApiKey"]).To(Equal("s*************3")) + + // Check Spotify.Secret (partially masked) + spotify, ok := resp.Config["Spotify"].(map[string]interface{}) + Expect(ok).To(BeTrue()) + Expect(spotify["Secret"]).To(Equal("s**************6")) + + // 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]interface{}) + 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]interface{}) + 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")) + Expect(redactValue("Spotify.Secret", "verylongsecret123")).To(Equal("v***************3")) + }) + + 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("Spotify.ID", "abc")).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/inspect.go b/server/nativeapi/inspect.go new file mode 100644 index 0000000..3178395 --- /dev/null +++ b/server/nativeapi/inspect.go @@ -0,0 +1,67 @@ +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(ctx).Get(id) + if err != nil { + return nil, err + } + + if file.Missing { + return nil, model.ErrNotFound + } + + return core.Inspect(file.AbsolutePath(), file.LibraryID, 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 { + log.Error(ctx, "Error sending response to client", err) + } + } +} diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go new file mode 100644 index 0000000..1636e1d --- /dev/null +++ b/server/nativeapi/library.go @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..9503384 --- /dev/null +++ b/server/nativeapi/library_test.go @@ -0,0 +1,424 @@ +package nativeapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + + "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/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 ds model.DataStore + var router http.Handler + var adminUser, regularUser model.User + var library1, library2 model.Library + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ds = &tests.MockDataStore{} + auth.Init(ds) + nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), 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(context.TODO()).Put(&adminUser)).To(Succeed()) + Expect(ds.User(context.TODO()).Put(®ularUser)).To(Succeed()) + Expect(ds.Library(context.TODO()).Put(&library1)).To(Succeed()) + Expect(ds.Library(context.TODO()).Put(&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(context.TODO()).SetUserLibraries(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/missing.go b/server/nativeapi/missing.go new file mode 100644 index 0000000..2b455e6 --- /dev/null +++ b/server/nativeapi/missing.go @@ -0,0 +1,94 @@ +package nativeapi + +import ( + "context" + "errors" + "maps" + "net/http" + + "github.com/Masterminds/squirrel" + "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 { + model.ResourceRepository + mfRepo model.MediaFileRepository +} + +func newMissingRepository(ds model.DataStore) rest.RepositoryConstructor { + return func(ctx context.Context) rest.Repository { + return &missingRepository{mfRepo: ds.MediaFile(ctx), ResourceRepository: ds.Resource(ctx, model.MediaFile{})} + } +} + +func (r *missingRepository) Count(options ...rest.QueryOptions) (int64, error) { + opt := r.parseOptions(options) + return r.ResourceRepository.Count(opt) +} + +func (r *missingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { + opt := r.parseOptions(options) + return r.ResourceRepository.ReadAll(opt) +} + +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(id string) (any, error) { + all, err := r.mfRepo.GetAll(model.QueryOptions{Filters: squirrel.And{ + squirrel.Eq{"id": id}, + squirrel.Eq{"missing": true}, + }}) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, model.ErrNotFound + } + return all[0], nil +} + +func (r *missingRepository) EntityName() string { + return "missing_files" +} + +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) + } +} + +var _ model.ResourceRepository = &missingRepository{} diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go new file mode 100644 index 0000000..969650e --- /dev/null +++ b/server/nativeapi/native_api.go @@ -0,0 +1,247 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "html" + "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/metrics" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/server" +) + +type Router struct { + http.Handler + ds model.DataStore + share core.Share + playlists core.Playlists + insights metrics.Insights + libs core.Library + maintenance core.Maintenance +} + +func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router { + r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance} + r.Handler = r.routes() + return r +} + +func (api *Router) routes() http.Handler { + r := chi.NewRouter() + + // Public + api.RX(r, "/translation", newTranslationRepository, false) + + // Protected + r.Group(func(r chi.Router) { + r.Use(server.Authenticator(api.ds)) + r.Use(server.JWTRefresher) + r.Use(server.UpdateLastAccessMiddleware(api.ds)) + api.R(r, "/user", model.User{}, true) + api.R(r, "/song", model.MediaFile{}, false) + api.R(r, "/album", model.Album{}, false) + api.R(r, "/artist", model.Artist{}, false) + api.R(r, "/genre", model.Genre{}, false) + api.R(r, "/player", model.Player{}, true) + api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) + api.R(r, "/radio", model.Radio{}, true) + api.R(r, "/tag", model.Tag{}, true) + if conf.Server.EnableSharing { + api.RX(r, "/share", api.share.NewRepository, true) + } + + api.addPlaylistRoute(r) + api.addPlaylistTrackRoute(r) + api.addSongPlaylistsRoute(r) + api.addQueueRoute(r) + api.addMissingFilesRoute(r) + api.addKeepAliveRoute(r) + api.addInsightsRoute(r) + + r.With(adminOnlyMiddleware).Group(func(r chi.Router) { + api.addInspectRoute(r) + api.addConfigRoute(r) + api.addUserLibraryRoute(r) + api.RX(r, "/library", api.libs.NewRepository, true) + }) + }) + + return r +} + +func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model) + } + api.RX(r, pathPrefix, constructor, persistable) +} + +func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) { + r.Route(pathPrefix, func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + if persistable { + r.Post("/", rest.Post(constructor)) + } + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + if persistable { + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + } + }) + }) +} + +func (api *Router) addPlaylistRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Playlist{}) + } + + r.Route("/playlist", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Content-type") == "application/json" { + rest.Post(constructor)(w, r) + return + } + createPlaylistFromM3U(api.playlists)(w, r) + }) + + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + }) + }) +} + +func (api *Router) addPlaylistTrackRoute(r chi.Router) { + r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + getPlaylist(api.ds)(w, r) + }) + r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) { + r.Delete("/", func(w http.ResponseWriter, r *http.Request) { + deleteFromPlaylist(api.ds)(w, r) + }) + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + addToPlaylist(api.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.ds)(w, r) + }) + r.Put("/", func(w http.ResponseWriter, r *http.Request) { + reorderItem(api.ds)(w, r) + }) + r.Delete("/", func(w http.ResponseWriter, r *http.Request) { + deleteFromPlaylist(api.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.ds)(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) { + api.RX(r, "/", newMissingRepository(api.ds), false) + r.Delete("/", deleteMissingFiles(api.maintenance)) + }) +} + +func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []string) { + var resp []byte + var err error + if len(ids) == 1 { + resp = []byte(`{"id":"` + html.EscapeString(ids[0]) + `"}`) + } else { + resp, err = json.Marshal(&struct { + Ids []string `json:"ids"` + }{Ids: ids}) + if err != nil { + log.Error(r.Context(), "Error marshaling response", "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 (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) + `}`)) + } 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 new file mode 100644 index 0000000..b520426 --- /dev/null +++ b/server/nativeapi/native_api_song_test.go @@ -0,0 +1,431 @@ +package nativeapi + +import ( + "bytes" + "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" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Song Endpoints", func() { + var ( + router http.Handler + ds *tests.MockDataStore + mfRepo *tests.MockMediaFileRepo + userRepo *tests.MockedUserRepo + w *httptest.ResponseRecorder + testUser model.User + testSongs model.MediaFiles + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + 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(&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, nil, nil, core.NewMockLibraryService(), 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(&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(®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/native_api_suite_test.go b/server/nativeapi/native_api_suite_test.go new file mode 100644 index 0000000..70cd7b2 --- /dev/null +++ b/server/nativeapi/native_api_suite_test.go @@ -0,0 +1,17 @@ +package nativeapi + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNativeApi(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Native RESTful API Suite") +} diff --git a/server/nativeapi/playlists.go b/server/nativeapi/playlists.go new file mode 100644 index 0000000..17af194 --- /dev/null +++ b/server/nativeapi/playlists.go @@ -0,0 +1,244 @@ +package nativeapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/req" +) + +type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc + +func getPlaylist(ds model.DataStore) http.HandlerFunc { + // Add a middleware to capture the playlistId + wrapper := func(handler restHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + constructor := func(ctx context.Context) rest.Repository { + plsRepo := ds.Playlist(ctx) + plsId := chi.URLParam(r, "playlistId") + p := req.Params(r) + start := p.Int64Or("_start", 0) + return plsRepo.Tracks(plsId, start == 0) + } + + handler(constructor).ServeHTTP(w, r) + } + } + + return func(w http.ResponseWriter, r *http.Request) { + accept := r.Header.Get("accept") + if strings.ToLower(accept) == "audio/x-mpegurl" { + handleExportPlaylist(ds)(w, r) + return + } + wrapper(rest.GetAll)(w, r) + } +} + +func getPlaylistTrack(ds model.DataStore) http.HandlerFunc { + // Add a middleware to capture the playlistId + wrapper := func(handler restHandler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + constructor := func(ctx context.Context) rest.Repository { + plsRepo := ds.Playlist(ctx) + plsId := chi.URLParam(r, "playlistId") + return plsRepo.Tracks(plsId, true) + } + + handler(constructor).ServeHTTP(w, r) + } + } + + return wrapper(rest.Get) +} + +func createPlaylistFromM3U(playlists core.Playlists) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + pls, err := playlists.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(pls.ToM3U8())) + if err != nil { + log.Error(ctx, "Error sending m3u contents", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +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") + pls, err := plsRepo.GetWithTracks(plsId, true, false) + if errors.Is(err, model.ErrNotFound) { + log.Warn(r.Context(), "Playlist not found", "playlistId", plsId) + http.Error(w, "not found", http.StatusNotFound) + return + } + if err != nil { + log.Error(r.Context(), "Error retrieving the playlist", "playlistId", plsId, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + log.Debug(ctx, "Exporting playlist as M3U", "playlistId", plsId, "name", pls.Name) + w.Header().Set("Content-Type", "audio/x-mpegurl") + disposition := fmt.Sprintf("attachment; filename=\"%s.m3u\"", pls.Name) + w.Header().Set("Content-Disposition", disposition) + + _, err = w.Write([]byte(pls.ToM3U8())) + if err != nil { + log.Error(ctx, "Error sending playlist", "name", pls.Name) + return + } + } +} + +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 := ds.WithTxImmediate(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) + return + } + if err != nil { + log.Error(r.Context(), "Error deleting tracks from playlist", "playlistId", playlistId, "ids", ids, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeDeleteManyResponse(w, r, ids) + } +} + +func addToPlaylist(ds model.DataStore) http.HandlerFunc { + type addTracksPayload struct { + Ids []string `json:"ids"` + AlbumIds []string `json:"albumIds"` + ArtistIds []string `json:"artistIds"` + Discs []model.DiscID `json:"discs"` + } + + return func(w http.ResponseWriter, r *http.Request) { + p := req.Params(r) + playlistId, _ := p.String(":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 = tracksRepo.Add(payload.Ids); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + count += c + if c, err = tracksRepo.AddAlbums(payload.AlbumIds); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + count += c + if c, err = tracksRepo.AddArtists(payload.ArtistIds); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + count += c + 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) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func reorderItem(ds model.DataStore) http.HandlerFunc { + type reorderPayload struct { + InsertBefore string `json:"insert_before"` + } + + return func(w http.ResponseWriter, r *http.Request) { + p := req.Params(r) + playlistId, _ := p.String(":playlistId") + id := p.IntOr(":id", 0) + if id == 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + var payload reorderPayload + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + newPos, err := strconv.Atoi(payload.InsertBefore) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId, true) + err = tracksRepo.Reorder(id, newPos) + if errors.Is(err, rest.ErrPermissionDenied) { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + _, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id))) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +func getSongPlaylists(ds model.DataStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + p := req.Params(r) + trackId, _ := p.String(":id") + playlists, err := ds.Playlist(r.Context()).GetPlaylists(trackId) + 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) + } +} diff --git a/server/nativeapi/queue.go b/server/nativeapi/queue.go new file mode 100644 index 0000000..0a31366 --- /dev/null +++ b/server/nativeapi/queue.go @@ -0,0 +1,214 @@ +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(ctx).Retrieve(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(ctx) + pq, err := repo.RetrieveWithMediaFiles(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) + } +} + +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(ctx).Store(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(ctx).Store(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(ctx).Clear(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 new file mode 100644 index 0000000..ef971ee --- /dev/null +++ b/server/nativeapi/queue_test.go @@ -0,0 +1,282 @@ +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/navidrome/navidrome/utils/gg" + . "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(&user) + ds = &tests.MockDataStore{MockedPlayQueue: repo, MockedUser: userRepo, MockedProperty: &tests.MockedPropertyRepo{}} + }) + + Describe("POST /queue", func() { + It("saves the queue", func() { + payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(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: gg.P([]string{}), Current: gg.P(0), Position: gg.P(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: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(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: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(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: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(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: gg.P(2), Position: gg.P(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: gg.P([]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: gg.P([]string{"s1", "s2"}), Current: gg.P(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: gg.P([]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: gg.P(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: gg.P(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/translations.go b/server/nativeapi/translations.go new file mode 100644 index 0000000..d47b6e2 --- /dev/null +++ b/server/nativeapi/translations.go @@ -0,0 +1,123 @@ +package nativeapi + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "path" + "strings" + "sync" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/resources" +) + +type translation struct { + ID string `json:"id"` + Name string `json:"name"` + Data string `json:"data"` +} + +func newTranslationRepository(context.Context) rest.Repository { + return &translationRepository{} +} + +type translationRepository struct{} + +func (r *translationRepository) Read(id string) (interface{}, error) { + translations, _ := loadTranslations() + if t, ok := translations[id]; ok { + return t, nil + } + return nil, rest.ErrNotFound +} + +// Count simple implementation, does not support any `options` +func (r *translationRepository) Count(...rest.QueryOptions) (int64, error) { + _, count := loadTranslations() + return count, nil +} + +// ReadAll simple implementation, only returns IDs. Does not support any `options` +func (r *translationRepository) ReadAll(...rest.QueryOptions) (interface{}, error) { + translations, _ := loadTranslations() + var result []translation + for _, t := range translations { + t.Data = "" + result = append(result, t) + } + return result, nil +} + +func (r *translationRepository) EntityName() string { + return "translation" +} + +func (r *translationRepository) NewInstance() interface{} { + return &translation{} +} + +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()) + if err != nil { + log.Error("Error loading translation file", "file", f.Name(), err) + continue + } + translations[t.ID] = t + languages = append(languages, t.ID) + } + log.Info("Loaded translations", "languages", languages) + return translations, int64(len(translations)) +}) + +func loadTranslation(fsys fs.FS, fileName string) (translation translation, err error) { + // Get id and full path + name := path.Base(fileName) + id := strings.TrimSuffix(name, path.Ext(name)) + filePath := path.Join(consts.I18nFolder, name) + + // Load translation from json file + file, err := fsys.Open(filePath) + if err != nil { + return + } + data, err := io.ReadAll(file) + if err != nil { + return + } + var out map[string]interface{} + if err = json.Unmarshal(data, &out); err != nil { + return + } + + // Compress JSON + buf := new(bytes.Buffer) + if err = json.Compact(buf, data); err != nil { + return + } + + translation.Data = buf.String() + translation.Name = out["languageName"].(string) + translation.ID = id + return +} + +var _ rest.Repository = (*translationRepository)(nil) diff --git a/server/nativeapi/translations_test.go b/server/nativeapi/translations_test.go new file mode 100644 index 0000000..c49c26c --- /dev/null +++ b/server/nativeapi/translations_test.go @@ -0,0 +1,47 @@ +package nativeapi + +import ( + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/resources" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Translations", func() { + Describe("I18n files", func() { + It("contains only valid json language files", func() { + fsys := resources.FS() + dir, _ := fsys.Open(consts.I18nFolder) + files, _ := dir.(fs.ReadDirFile).ReadDir(-1) + for _, f := range files { + name := filepath.Base(f.Name()) + filePath := filepath.Join(consts.I18nFolder, name) + file, _ := fsys.Open(filePath) + data, _ := io.ReadAll(file) + var out map[string]interface{} + + Expect(filepath.Ext(filePath)).To(Equal(".json"), filePath) + Expect(json.Unmarshal(data, &out)).To(BeNil(), filePath) + Expect(out["languageName"]).ToNot(BeEmpty(), filePath) + } + }) + }) + + Describe("loadTranslation", func() { + It("loads a translation file correctly", func() { + fs := os.DirFS("ui/src") + tr, err := loadTranslation(fs, "en.json") + Expect(err).To(BeNil()) + Expect(tr.ID).To(Equal("en")) + Expect(tr.Name).To(Equal("English")) + var out map[string]interface{} + Expect(json.Unmarshal([]byte(tr.Data), &out)).To(BeNil()) + }) + }) +}) diff --git a/server/public/encode_id.go b/server/public/encode_id.go new file mode 100644 index 0000000..6adf0e7 --- /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/utils/gg" +) + +func ImageURL(r *http.Request, artID model.ArtworkID, size int) string { + token := encodeArtworkID(artID) + uri := path.Join(consts.URLPathPublicImages, token) + params := url.Values{} + if size > 0 { + params.Add("size", strconv.Itoa(size)) + } + return publicURL(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(V(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 0000000..efd252e --- /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 new file mode 100644 index 0000000..6aa35c3 --- /dev/null +++ b/server/public/handle_downloads.go @@ -0,0 +1,18 @@ +package public + +import ( + "net/http" + + "github.com/navidrome/navidrome/utils/req" +) + +func (pub *Router) handleDownloads(w http.ResponseWriter, r *http.Request) { + id, err := req.Params(r).String(":id") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + err = pub.archiver.ZipShare(r.Context(), id, w) + checkShareError(r.Context(), w, err, id) +} diff --git a/server/public/handle_images.go b/server/public/handle_images.go new file mode 100644 index 0000000..55a851c --- /dev/null +++ b/server/public/handle_images.go @@ -0,0 +1,67 @@ +package public + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/req" +) + +func (pub *Router) handleImages(w http.ResponseWriter, r *http.Request) { + // If context is already canceled, discard request without further processing + if r.Context().Err() != nil { + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + p := req.Params(r) + id, _ := p.String(":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, err.Error(), http.StatusBadRequest) + return + } + size := p.IntOr("size", 0) + square := p.BoolOr("square", false) + + imgReader, lastUpdate, err := pub.artwork.Get(ctx, artId, size, square) + switch { + case errors.Is(err, context.Canceled): + return + case errors.Is(err, model.ErrNotFound): + log.Warn(r, "Couldn't find coverArt", "id", id, err) + http.Error(w, "Artwork not found", http.StatusNotFound) + return + case errors.Is(err, artwork.ErrUnavailable): + log.Debug(r, "Item does not have artwork", "id", id, err) + http.Error(w, "Artwork not found", http.StatusNotFound) + return + case err != nil: + log.Error(r, "Error retrieving coverArt", "id", id, err) + http.Error(w, "Error retrieving coverArt", http.StatusInternalServerError) + return + } + + 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) + } +} diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go new file mode 100644 index 0000000..61f3fba --- /dev/null +++ b/server/public/handle_shares.go @@ -0,0 +1,94 @@ +package public + +import ( + "context" + "errors" + "net/http" + "path" + + "github.com/navidrome/navidrome/consts" + "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/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) + return + } + + // If requested file is a UI asset, just serve it + _, err = ui.BuildAssets().Open(id) + if err == nil { + pub.assetsHandler.ServeHTTP(w, r) + 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.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.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "audio/x-mpegurl") + _, _ = w.Write([]byte(s.ToM3U8())) +} + +func checkShareError(ctx context.Context, w http.ResponseWriter, err error, id string) { + switch { + case errors.Is(err, model.ErrExpired): + log.Error(ctx, "Share expired", "id", id, err) + http.Error(w, "Share not available anymore", http.StatusGone) + case errors.Is(err, model.ErrNotFound): + log.Error(ctx, "Share not found", "id", id, err) + http.Error(w, "Share not found", http.StatusNotFound) + case errors.Is(err, model.ErrNotAuthorized): + log.Error(ctx, "Share is not downloadable", "id", id, err) + http.Error(w, "This share is not downloadable", http.StatusForbidden) + case err != nil: + log.Error(ctx, "Error retrieving share", "id", id, err) + http.Error(w, "Error retrieving share", http.StatusInternalServerError) + } +} + +func (pub *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(r, path.Join(consts.URLPathPublic, "s", id), nil) + } + return &s +} diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go new file mode 100644 index 0000000..cf120f0 --- /dev/null +++ b/server/public/handle_streams.go @@ -0,0 +1,105 @@ +package public + +import ( + "context" + "errors" + "io" + "net/http" + "strconv" + + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/utils/req" +) + +func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + p := req.Params(r) + tokenId, _ := p.String(":id") + info, err := decodeStreamInfo(tokenId) + if err != nil { + log.Error(ctx, "Error parsing shared stream info", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + + stream, err := pub.streamer.NewStream(ctx, info.id, info.format, info.bitrate, 0) + if err != nil { + log.Error(ctx, "Error starting shared stream", err) + http.Error(w, "invalid request", http.StatusInternalServerError) + } + + // 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) { + log.Error("Error closing shared stream", "id", info.id, "file", stream.Name(), err) + } + }() + + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) + + 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 := p.BoolOr("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.IsGreaterOrEqualTo(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) + } + } + } + } +} + +type shareTrackInfo struct { + id string + format string + bitrate int +} + +func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { + token, err := auth.TokenAuth.Decode(tokenString) + if err != nil { + return shareTrackInfo{}, err + } + if token == nil { + return shareTrackInfo{}, errors.New("unauthorized") + } + err = jwt.Validate(token, jwt.WithRequiredClaim("id")) + if err != nil { + return shareTrackInfo{}, err + } + 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/public.go b/server/public/public.go new file mode 100644 index 0000000..03ccaee --- /dev/null +++ b/server/public/public.go @@ -0,0 +1,85 @@ +package public + +import ( + "net/http" + "net/url" + "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/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" + "github.com/navidrome/navidrome/ui" +) + +type Router struct { + http.Handler + artwork artwork.Artwork + streamer core.MediaStreamer + archiver core.Archiver + share core.Share + assetsHandler http.Handler + ds model.DataStore +} + +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() + + return p +} + +func (pub *Router) routes() http.Handler { + r := chi.NewRouter() + + r.Group(func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Group(func(r chi.Router) { + 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}", pub.handleImages) + }) + if conf.Server.EnableSharing { + r.HandleFunc("/s/{id}", pub.handleStream) + if conf.Server.EnableDownloads { + r.HandleFunc("/d/{id}", pub.handleDownloads) + } + r.HandleFunc("/{id}/m3u", pub.handleM3U) + r.HandleFunc("/{id}", pub.handleShares) + r.HandleFunc("/", pub.handleShares) + r.Handle("/*", pub.assetsHandler) + } + }) + return r +} + +func ShareURL(r *http.Request, id string) string { + uri := path.Join(consts.URLPathPublic, id) + return publicURL(r, uri, nil) +} + +func publicURL(r *http.Request, u string, params url.Values) string { + if conf.Server.ShareURL != "" { + shareUrl, _ := url.Parse(conf.Server.ShareURL) + buildUrl, _ := url.Parse(u) + buildUrl.Scheme = shareUrl.Scheme + buildUrl.Host = shareUrl.Host + if len(params) > 0 { + buildUrl.RawQuery = params.Encode() + } + return buildUrl.String() + } + return server.AbsoluteURL(r, u, params) +} diff --git a/server/public/public_suite_test.go b/server/public/public_suite_test.go new file mode 100644 index 0000000..ea6029f --- /dev/null +++ b/server/public/public_suite_test.go @@ -0,0 +1,17 @@ +package public + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPublicEndpoints(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Public Endpoints Suite") +} diff --git a/server/public/public_test.go b/server/public/public_test.go new file mode 100644 index 0000000..c45fadf --- /dev/null +++ b/server/public/public_test.go @@ -0,0 +1,56 @@ +package public + +import ( + "net/http" + "net/url" + "path" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("publicURL", func() { + When("ShareURL is set", func() { + BeforeEach(func() { + conf.Server.ShareURL = "http://share.myotherserver.com" + }) + It("uses the config value instead of AbsoluteURL", func() { + r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) + uri := path.Join(consts.URLPathPublic, "123") + actual := publicURL(r, uri, nil) + Expect(actual).To(Equal("http://share.myotherserver.com/share/123")) + }) + It("concatenates params if provided", func() { + r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) + uri := path.Join(consts.URLPathPublicImages, "123") + params := url.Values{ + "size": []string{"300"}, + } + actual := publicURL(r, uri, params) + Expect(actual).To(Equal("http://share.myotherserver.com/share/img/123?size=300")) + + }) + }) + When("ShareURL is not set", func() { + BeforeEach(func() { + conf.Server.ShareURL = "" + }) + It("uses AbsoluteURL", func() { + r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) + uri := path.Join(consts.URLPathPublic, "123") + actual := publicURL(r, uri, nil) + Expect(actual).To(Equal("https://myserver.com/share/123")) + }) + It("concatenates params if provided", func() { + r, _ := http.NewRequest("GET", "https://myserver.com/share/123", nil) + uri := path.Join(consts.URLPathPublicImages, "123") + params := url.Values{ + "size": []string{"300"}, + } + actual := publicURL(r, uri, params) + Expect(actual).To(Equal("https://myserver.com/share/img/123?size=300")) + }) + }) +}) diff --git a/server/serve_index.go b/server/serve_index.go new file mode 100644 index 0000000..38e6469 --- /dev/null +++ b/server/serve_index.go @@ -0,0 +1,183 @@ +package server + +import ( + "encoding/json" + "html/template" + "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/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" +) + +func Index(ds model.DataStore, fs fs.FS) http.HandlerFunc { + return serveIndex(ds, fs, nil) +} + +func IndexWithShare(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.HandlerFunc { + return serveIndex(ds, fs, shareInfo) +} + +// 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(r.Context()).CountAll() + firstTime := c == 0 && err == nil + + t, err := getIndexTemplate(r, fs) + if err != nil { + http.NotFound(w, r) + return + } + 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.SanitizeText(conf.Server.UIWelcomeMessage), + "maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists, + "enableTranscodingConfig": conf.Server.EnableTranscodingConfig, + "enableDownloads": conf.Server.EnableDownloads, + "enableFavourites": conf.Server.EnableFavourites, + "enableStarRating": conf.Server.EnableStarRating, + "defaultTheme": conf.Server.DefaultTheme, + "defaultLanguage": conf.Server.DefaultLanguage, + "defaultUIVolume": conf.Server.DefaultUIVolume, + "enableCoverAnimation": conf.Server.EnableCoverAnimation, + "enableNowPlaying": conf.Server.EnableNowPlaying, + "gaTrackingId": conf.Server.GATrackingID, + "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), + "devActivityPanel": conf.Server.DevActivityPanel, + "enableUserEditing": conf.Server.EnableUserEditing, + "enableSharing": conf.Server.EnableSharing, + "shareURL": conf.Server.ShareURL, + "defaultDownloadableShare": conf.Server.DefaultDownloadableShare, + "devSidebarPlaylists": conf.Server.DevSidebarPlaylists, + "lastFMEnabled": conf.Server.LastFM.Enabled, + "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, + "defaultDownsamplingFormat": conf.Server.DefaultDownsamplingFormat, + "separator": string(os.PathSeparator), + "enableInspect": conf.Server.Inspect.Enabled, + } + if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") { + appConfig["loginBackgroundURL"] = path.Join(conf.Server.BasePath, conf.Server.UILoginBackgroundURL) + } + auth := handleLoginFromHeaders(ds, r) + if auth != nil { + appConfig["auth"] = auth + } + appConfigJson, err := json.Marshal(appConfig) + if err != nil { + log.Error(r, "Error converting config to JSON", "config", appConfig, err) + } else { + log.Trace(r, "Injecting config in index.html", "config", string(appConfigJson)) + } + + log.Debug("UI configuration", "appConfig", appConfig) + version := consts.Version + if version != "dev" { + version = "v" + version + } + data := map[string]interface{}{ + "AppConfig": string(appConfigJson), + "Version": version, + } + addShareData(r, data, shareInfo) + + w.Header().Set("Content-Type", "text/html") + err = t.Execute(w, data) + if err != nil { + log.Error(r, "Could not execute `index.html` template", err) + } + } +} + +func getIndexTemplate(r *http.Request, fs fs.FS) (*template.Template, error) { + t := template.New("initial state") + indexHtml, err := fs.Open("index.html") + if err != nil { + log.Error(r, "Could not find `index.html` template", err) + return nil, err + } + indexStr, err := io.ReadAll(indexHtml) + if err != nil { + log.Error(r, "Could not read from `index.html`", err) + return nil, err + } + t, err = t.Parse(string(indexStr)) + if err != nil { + log.Error(r, "Error parsing `index.html`", err) + return nil, err + } + return t, nil +} + +type shareData struct { + ID string `json:"id"` + Description string `json:"description"` + Downloadable bool `json:"downloadable"` + Tracks []shareTrack `json:"tracks"` +} + +type shareTrack struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` + Duration float32 `json:"duration,omitempty"` +} + +func addShareData(r *http.Request, data map[string]interface{}, shareInfo *model.Share) { + ctx := r.Context() + if shareInfo == nil || shareInfo.ID == "" { + return + } + sd := shareData{ + ID: shareInfo.ID, + Description: shareInfo.Description, + Downloadable: shareInfo.Downloadable, + } + sd.Tracks = slice.Map(shareInfo.Tracks, func(mf model.MediaFile) shareTrack { + return shareTrack{ + ID: mf.ID, + Title: mf.Title, + Artist: mf.Artist, + Album: mf.Album, + Duration: mf.Duration, + UpdatedAt: mf.UpdatedAt, + } + }) + + shareInfoJson, err := json.Marshal(sd) + if err != nil { + log.Error(ctx, "Error converting shareInfo to JSON", "config", shareInfo, err) + } else { + log.Trace(ctx, "Injecting shareInfo in index.html", "config", string(shareInfoJson)) + } + + if shareInfo.Description != "" { + data["ShareDescription"] = shareInfo.Description + } else { + data["ShareDescription"] = shareInfo.Contents + } + data["ShareURL"] = shareInfo.URL + data["ShareImageURL"] = shareInfo.ImageURL + data["ShareInfo"] = string(shareInfoJson) +} diff --git a/server/serve_index_test.go b/server/serve_index_test.go new file mode 100644 index 0000000..4f179f2 --- /dev/null +++ b/server/serve_index_test.go @@ -0,0 +1,331 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "regexp" + "strconv" + "strings" + "time" + + "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" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("serveIndex", func() { + var ds model.DataStore + mockUser := &mockedUserRepo{} + fs := os.DirFS("tests/fixtures") + + BeforeEach(func() { + ds = &tests.MockDataStore{MockedUser: mockUser} + DeferCleanup(configtest.SetupConfig()) + }) + + It("adds app_config to index.html", func() { + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + Expect(w.Code).To(Equal(200)) + config := extractAppConfig(w.Body.String()) + Expect(config).To(BeAssignableToTypeOf(map[string]any{})) + }) + + It("sets firstTime = true when User table is empty", 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("firstTime", true)) + }) + + It("sets firstTime = false when User table is not empty", func() { + mockUser.empty = false + 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("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("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true), + Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true), + Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"), + 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("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), + ) + + DescribeTable("sets other UI configuration values", + func(configKey string, expectedValueFunc func() any) { + 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, 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) }), + ) + + Describe("loginBackgroundURL", func() { + Context("empty BaseURL", func() { + BeforeEach(func() { + conf.Server.BasePath = "/" + }) + When("it is the default URL", func() { + It("points to the default URL", func() { + conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL + 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("loginBackgroundURL", consts.DefaultUILoginBackgroundURL)) + }) + }) + When("it is the default offline URL", func() { + It("points to the offline URL", func() { + conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline + 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("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline)) + }) + }) + When("it is a custom URL", func() { + It("points to the offline URL", func() { + conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg" + 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("loginBackgroundURL", "https://example.com/images/1.jpg")) + }) + }) + }) + Context("with a BaseURL", func() { + BeforeEach(func() { + conf.Server.BasePath = "/music" + }) + When("it is the default URL", func() { + It("points to the default URL with BaseURL prefix", func() { + conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURL + 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("loginBackgroundURL", "/music"+consts.DefaultUILoginBackgroundURL)) + }) + }) + When("it is the default offline URL", func() { + It("points to the offline URL", func() { + conf.Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline + 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("loginBackgroundURL", consts.DefaultUILoginBackgroundURLOffline)) + }) + }) + When("it is a custom URL", func() { + It("points to the offline URL", func() { + conf.Server.UILoginBackgroundURL = "https://example.com/images/1.jpg" + 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("loginBackgroundURL", "https://example.com/images/1.jpg")) + }) + }) + }) + }) +}) + +var _ = Describe("addShareData", func() { + var ( + r *http.Request + data map[string]any + shareInfo *model.Share + ) + + BeforeEach(func() { + data = make(map[string]any) + r = httptest.NewRequest("GET", "/", nil) + }) + + Context("when shareInfo is nil or has an empty ID", func() { + It("should not modify data", func() { + addShareData(r, data, nil) + Expect(data).To(BeEmpty()) + + shareInfo = &model.Share{} + addShareData(r, data, shareInfo) + Expect(data).To(BeEmpty()) + }) + }) + + Context("when shareInfo is not nil and has a non-empty ID", func() { + BeforeEach(func() { + shareInfo = &model.Share{ + ID: "testID", + Description: "Test description", + Downloadable: true, + Tracks: []model.MediaFile{ + { + ID: "track1", + Title: "Track 1", + Artist: "Artist 1", + Album: "Album 1", + Duration: 100, + UpdatedAt: time.Date(2023, time.Month(3), 27, 0, 0, 0, 0, time.UTC), + }, + { + ID: "track2", + Title: "Track 2", + Artist: "Artist 2", + Album: "Album 2", + Duration: 200, + UpdatedAt: time.Date(2023, time.Month(3), 26, 0, 0, 0, 0, time.UTC), + }, + }, + Contents: "Test contents", + URL: "https://example.com/share/testID", + ImageURL: "https://example.com/share/testID/image", + } + }) + + It("should populate data with shareInfo data", func() { + addShareData(r, data, shareInfo) + + Expect(data["ShareDescription"]).To(Equal(shareInfo.Description)) + Expect(data["ShareURL"]).To(Equal(shareInfo.URL)) + Expect(data["ShareImageURL"]).To(Equal(shareInfo.ImageURL)) + + var shareData shareData + err := json.Unmarshal([]byte(data["ShareInfo"].(string)), &shareData) + Expect(err).NotTo(HaveOccurred()) + Expect(shareData.ID).To(Equal(shareInfo.ID)) + Expect(shareData.Description).To(Equal(shareInfo.Description)) + Expect(shareData.Downloadable).To(Equal(shareInfo.Downloadable)) + + Expect(shareData.Tracks).To(HaveLen(len(shareInfo.Tracks))) + for i, track := range shareData.Tracks { + Expect(track.ID).To(Equal(shareInfo.Tracks[i].ID)) + Expect(track.Title).To(Equal(shareInfo.Tracks[i].Title)) + Expect(track.Artist).To(Equal(shareInfo.Tracks[i].Artist)) + Expect(track.Album).To(Equal(shareInfo.Tracks[i].Album)) + Expect(track.Duration).To(Equal(shareInfo.Tracks[i].Duration)) + Expect(track.UpdatedAt).To(Equal(shareInfo.Tracks[i].UpdatedAt)) + } + }) + + Context("when shareInfo has an empty description", func() { + BeforeEach(func() { + shareInfo.Description = "" + }) + + It("should use shareInfo.Contents as ShareDescription", func() { + addShareData(r, data, shareInfo) + Expect(data["ShareDescription"]).To(Equal(shareInfo.Contents)) + }) + }) + }) +}) + +var appConfigRegex = regexp.MustCompile(`(?m)window.__APP_CONFIG__=(.*);</script>`) + +func extractAppConfig(body string) map[string]any { + config := make(map[string]any) + match := appConfigRegex.FindStringSubmatch(body) + if match == nil { + return config + } + str, err := strconv.Unquote(match[1]) + if err != nil { + panic(fmt.Sprintf("%s: %s", match[1], err)) + } + if err := json.Unmarshal([]byte(str), &config); err != nil { + panic(err) + } + return config +} + +type mockedUserRepo struct { + model.UserRepository + empty bool +} + +func (u *mockedUserRepo) CountAll(...model.QueryOptions) (int64, error) { + if u.empty { + return 0, nil + } + return 1, nil +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..39475a2 --- /dev/null +++ b/server/server.go @@ -0,0 +1,314 @@ +package server + +import ( + "bytes" + "cmp" + "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" +) + +type Server struct { + router chi.Router + ds model.DataStore + appRoot string + broker events.Broker + insights metrics.Insights +} + +func New(ds model.DataStore, broker events.Broker, insights metrics.Insights) *Server { + s := &Server{ds: ds, broker: broker, insights: insights} + initialSetup(ds) + auth.Init(s.ds) + s.initRoutes() + s.mountAuthenticationRoutes() + s.mountRootRedirector() + checkFFmpegInstallation() + checkExternalCredentials() + return s +} + +func (s *Server) MountRouter(description, urlPath string, subRouter http.Handler) { + urlPath = path.Join(conf.Server.BasePath, urlPath) + log.Info(fmt.Sprintf("Mounting %s routes", description), "path", urlPath) + s.router.Group(func(r chi.Router) { + r.Mount(urlPath, subRouter) + }) +} + +// Run starts the server with the given address, and if specified, with TLS enabled. +func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string, tlsKey string) error { + // Mount the router for the frontend assets + s.MountRouter("WebUI", consts.URLPathUI, s.frontendAssetsHandler()) + + // Create a new http.Server with the specified read header timeout and handler + server := &http.Server{ + ReadHeaderTimeout: consts.ServerReadHeaderTimeout, + Handler: s.router, + } + + // 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 strings.HasPrefix(addr, "unix:") { + socketPath := strings.TrimPrefix(addr, "unix:") + listener, err = createUnixSocketFile(socketPath, conf.Server.UnixSocketPerm) + if err != nil { + return 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) + } + } + + // 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) + } else { + // Start the HTTP server + err = server.Serve(listener) + } + if !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 + 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): + 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) + case <-ctx.Done(): + // If the context is done (i.e. the server should stop), proceed to shutting down the server + } + + // Try to stop the HTTP server gracefully + log.Info(ctx, "Stopping HTTP server") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + server.SetKeepAlivesEnabled(false) + if err := server.Shutdown(ctx); err != nil && !errors.Is(err, context.DeadlineExceeded) { + log.Error(ctx, "Unexpected error in http.Shutdown()", err) + } + 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{ + secureMiddleware(), + corsHandler(), + middleware.RequestID, + realIPMiddleware, + 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 + if conf.Server.DevActivityPanel { + r.Group(func(r chi.Router) { + r.Use(defaultMiddlewares...) + 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 + r.Group(func(r chi.Router) { + r.Use(defaultMiddlewares...) + r.Use(requestLogger) + s.router = r + }) +} + +func (s *Server) mountAuthenticationRoutes() chi.Router { + r := s.router + return r.Route(path.Join(conf.Server.BasePath, "/auth"), func(r chi.Router) { + if conf.Server.AuthRequestLimit > 0 { + log.Info("Login rate limit set", "requestLimit", conf.Server.AuthRequestLimit, + "windowLength", conf.Server.AuthWindowLength) + + rateLimiter := httprate.LimitByIP(conf.Server.AuthRequestLimit, conf.Server.AuthWindowLength) + 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") + + r.Post("/login", login(s.ds)) + } + r.Post("/createAdmin", createAdmin(s.ds)) + }) +} + +// Serve UI app assets +func (s *Server) mountRootRedirector() { + r := s.router + // Redirect root to UI URL + r.Get("/*", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, s.appRoot+"/", http.StatusFound) + }) + r.Get(s.appRoot, func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, s.appRoot+"/", http.StatusFound) + }) +} + +func (s *Server) frontendAssetsHandler() http.Handler { + r := chi.NewRouter() + + r.Handle("/", Index(s.ds, ui.BuildAssets())) + r.Handle("/*", http.StripPrefix(s.appRoot, http.FileServer(http.FS(ui.BuildAssets())))) + return r +} + +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 = cmp.Or(conf.Server.BaseScheme, "http") + buildUrl.Host = conf.Server.BaseHost + } else { + buildUrl.Scheme = r.URL.Scheme + buildUrl.Host = r.Host + } + } + if len(params) > 0 { + buildUrl.RawQuery = params.Encode() + } + return buildUrl.String() +} + +// 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) + 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 + } + } + + // 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 + } + + return false +} diff --git a/server/server_suite_test.go b/server/server_suite_test.go new file mode 100644 index 0000000..761983e --- /dev/null +++ b/server/server_suite_test.go @@ -0,0 +1,17 @@ +package server + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestServer(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Server Suite") +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..5ca03bf --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,259 @@ +package server + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "io/fs" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + "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("AbsoluteURL", func() { + 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://localhost/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("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://localhost/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("BaseURL has full URL", func() { + BeforeEach(func() { + conf.Server.BaseScheme = "https" + conf.Server.BaseHost = "myserver.com:8080" + conf.Server.BasePath = "/music" + }) + 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://localhost/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")) + }) + }) +}) + +var _ = Describe("createUnixSocketFile", func() { + var socketPath string + + BeforeEach(func() { + tempDir, _ := os.MkdirTemp("", "create_unix_socket_file_test") + socketPath = filepath.Join(tempDir, "test.sock") + DeferCleanup(func() { + _ = os.RemoveAll(tempDir) + }) + }) + + When("unixSocketPerm is valid", func() { + It("updates the permission of the unix socket file and returns nil", func() { + _, err := createUnixSocketFile(socketPath, "0777") + fileInfo, _ := os.Stat(socketPath) + actualPermission := fileInfo.Mode().Perm() + + Expect(actualPermission).To(Equal(os.FileMode(0777))) + Expect(err).ToNot(HaveOccurred()) + + }) + }) + + When("unixSocketPerm is invalid", func() { + It("returns an error", func() { + _, err := createUnixSocketFile(socketPath, "invalid") + Expect(err).To(HaveOccurred()) + + }) + }) + + When("file already exists", func() { + It("recreates the file as a socket with the right permissions", func() { + _, 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") + } + }) + }) + }) +}) diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go new file mode 100644 index 0000000..56cf469 --- /dev/null +++ b/server/subsonic/album_lists.go @@ -0,0 +1,285 @@ +package subsonic + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "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" +) + +func (api *Router) getAlbumList(r *http.Request) (model.Albums, int64, error) { + p := req.Params(r) + typ, err := p.String("type") + if err != nil { + return nil, 0, err + } + + var opts filter.Options + switch typ { + case "newest": + opts = filter.AlbumsByNewest() + case "recent": + opts = filter.AlbumsByRecent() + case "random": + opts = filter.AlbumsByRandom() + case "alphabeticalByName": + opts = filter.AlbumsByName() + case "alphabeticalByArtist": + opts = filter.AlbumsByArtist() + case "frequent": + opts = filter.AlbumsByFrequent() + case "starred": + opts = filter.ByStarred() + case "highest": + opts = filter.ByRating() + case "byGenre": + genre, err := p.String("genre") + if err != nil { + return nil, 0, err + } + opts = filter.ByGenre(genre) + case "byYear": + fromYear, err := p.Int("fromYear") + if err != nil { + return nil, 0, err + } + toYear, err := p.Int("toYear") + if err != nil { + return nil, 0, err + } + opts = filter.AlbumsByYear(fromYear, toYear) + default: + log.Error(r, "albumList type not implemented", "type", typ) + 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(r.Context()).GetAll(opts) + + if err != nil { + log.Error(r, "Error retrieving albums", err) + return nil, 0, newError(responses.ErrorGeneric, "internal error") + } + + count, err := api.ds.Album(r.Context()).CountAll(opts) + if err != nil { + log.Error(r, "Error counting albums", err) + return nil, 0, newError(responses.ErrorGeneric, "internal error") + } + + return albums, count, nil +} + +func (api *Router) GetAlbumList(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + albums, count, err := api.getAlbumList(r) + if err != nil { + return nil, err + } + + w.Header().Set("x-total-count", strconv.Itoa(int(count))) + + response := newResponse() + response.AlbumList = &responses.AlbumList{ + Album: slice.MapWithArg(albums, r.Context(), childFromAlbum), + } + return response, nil +} + +func (api *Router) GetAlbumList2(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + albums, pageCount, err := api.getAlbumList(r) + if err != nil { + return nil, err + } + + w.Header().Set("x-total-count", strconv.FormatInt(pageCount, 10)) + + response := newResponse() + response.AlbumList2 = &responses.AlbumList2{ + Album: slice.MapWithArg(albums, r.Context(), buildAlbumID3), + } + 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(ctx).GetAll(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(ctx).GetAll(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(ctx).GetAll(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) + if err != nil { + 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) + return response, nil +} + +func (api *Router) GetStarred2(r *http.Request) (*responses.Subsonic, error) { + artists, albums, mediaFiles, err := api.getStarredItems(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) + return response, nil +} + +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) + return nil, err + } + + response := newResponse() + response.NowPlaying = &responses.NowPlaying{} + var i int32 + response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + return responses.NowPlayingEntry{ + Child: childFromMediaFile(ctx, np.MediaFile), + UserName: np.Username, + MinutesAgo: int32(time.Since(np.Start).Minutes()), + PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerName: np.PlayerName, + } + }) + 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) + + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, err + } + opts := filter.SongsByRandom(genre, fromYear, toYear) + opts = filter.ApplyLibraryFilter(opts, musicFolderIds) + + songs, err := api.getSongs(r.Context(), 0, size, opts) + if err != nil { + log.Error(r, "Error retrieving random songs", err) + return nil, err + } + + response := newResponse() + response.RandomSongs = &responses.Songs{} + response.RandomSongs.Songs = slice.MapWithArg(songs, r.Context(), childFromMediaFile) + 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") + + // Get optional library IDs from musicFolderId parameter + musicFolderIds, err := selectedMusicFolderIds(r, false) + if err != nil { + return nil, err + } + opts := filter.ByGenre(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) + return nil, err + } + + response := newResponse() + response.SongsByGenre = &responses.Songs{} + response.SongsByGenre.Songs = slice.MapWithArg(songs, ctx, childFromMediaFile) + 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(ctx).GetAll(opts) +} diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go new file mode 100644 index 0000000..63c2614 --- /dev/null +++ b/server/subsonic/album_lists_test.go @@ -0,0 +1,542 @@ +package subsonic + +import ( + "context" + "errors" + "net/http/httptest" + + "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/subsonic/responses" + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/req" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Album Lists", func() { + var router *Router + 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(ctx).(*tests.MockAlbumRepo) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + w = httptest.NewRecorder() + }) + + Describe("GetAlbumList", func() { + It("should return list of the type specified", func() { + r := newGetRequest("type=newest", "offset=10", "size=20") + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + resp, err := router.GetAlbumList(w, r) + + Expect(err).ToNot(HaveOccurred()) + 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")) + Expect(mockRepo.Options.Offset).To(Equal(10)) + Expect(mockRepo.Options.Max).To(Equal(20)) + }) + + It("should fail if missing type parameter", func() { + r := newGetRequest() + _, err := router.GetAlbumList(w, r) + + Expect(err).To(MatchError(req.ErrMissingParam)) + }) + + It("should return error if call fails", func() { + mockRepo.SetError(true) + r := newGetRequest("type=newest") + + _, err := router.GetAlbumList(w, r) + + Expect(err).To(MatchError(errSubsonic)) + 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() { + It("should return list of the type specified", func() { + r := newGetRequest("type=newest", "offset=10", "size=20") + mockRepo.SetData(model.Albums{ + {ID: "1"}, {ID: "2"}, + }) + resp, err := router.GetAlbumList2(w, r) + + Expect(err).ToNot(HaveOccurred()) + 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")) + Expect(mockRepo.Options.Offset).To(Equal(10)) + Expect(mockRepo.Options.Max).To(Equal(20)) + }) + + It("should fail if missing type parameter", func() { + r := newGetRequest() + + _, err := router.GetAlbumList2(w, r) + + Expect(err).To(MatchError(req.ErrMissingParam)) + }) + + It("should return error if call fails", func() { + mockRepo.SetError(true) + r := newGetRequest("type=newest") + + _, err := router.GetAlbumList2(w, r) + + Expect(err).To(MatchError(errSubsonic)) + 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.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(ctx).(*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(ctx).(*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(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*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(ctx).(*tests.MockArtistRepo) + mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo) + mockMediaFileRepo = ds.MediaFile(ctx).(*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 new file mode 100644 index 0000000..f0e73c3 --- /dev/null +++ b/server/subsonic/api.go @@ -0,0 +1,361 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "net/http" + + "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" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/scrobbler" + "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/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +const Version = "1.16.1" + +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 core.MediaStreamer + archiver core.Archiver + players core.Players + provider external.Provider + playlists core.Playlists + scanner model.Scanner + broker events.Broker + scrobbler scrobbler.PlayTracker + share core.Share + playback playback.PlaybackServer + metrics metrics.Metrics +} + +func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver, + players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, + playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, + metrics metrics.Metrics, +) *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, + } + r.Handler = r.routes() + return r +} + +func (api *Router) routes() http.Handler { + r := chi.NewRouter() + + if conf.Server.Prometheus.Enabled { + r.Use(recordStats(api.metrics)) + } + + r.Use(postFormToQueryParams) + + // Public + h(r, "getOpenSubsonicExtensions", api.GetOpenSubsonicExtensions) + + // Protected + 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) + }) + 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) + if conf.Server.EnableNowPlaying { + h(r, "getNowPlaying", api.GetNowPlaying) + } else { + h501(r, "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, "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, "getUsers", api.GetUsers) + }) + r.Group(func(r chi.Router) { + r.Use(getPlayer(api.players)) + h(r, "getScanStatus", api.GetScanStatus) + h(r, "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) + }) + 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)) + 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) { + 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") + }) + return r +} + +// Add a Subsonic handler +func h(r chi.Router, path string, f handler) { + hr(r, path, func(_ http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + return f(r) + }) +} + +// Add a Subsonic handler that requires an 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 { + sendError(w, r, err) + return + } + if r.Context().Err() != nil { + if log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Warn(r.Context(), "Request was interrupted", "endpoint", r.URL.Path, r.Context().Err()) + } + return + } + if res != nil { + sendResponse(w, r, res) + } + } + addHandler(r, path, handle) +} + +// Add a handler that returns 501 - Not implemented. Used to signal that an endpoint is not implemented yet +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.Write([]byte("This endpoint is not implemented, but may be in future releases")) + } + addHandler(r, path, handle) + } +} + +// Add a handler that returns 410 - Gone. Used to signal that an endpoint will not be implemented +func h410(r chi.Router, paths ...string) { + for _, path := range paths { + handle := func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusGone) + _, _ = w.Write([]byte("This endpoint will not be implemented")) + } + addHandler(r, path, handle) + } +} + +func addHandler(r chi.Router, path string, handle func(w http.ResponseWriter, r *http.Request)) { + r.HandleFunc("/"+path, handle) + 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") + 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) { + subErr := mapToSubsonicError(err) + response := newResponse() + response.Status = responses.StatusFailed + response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()} + + sendResponse(w, r, response) +} + +func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) { + p := req.Params(r) + f, _ := p.String("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) + case "jsonp": + w.Header().Set("Content-Type", "application/javascript") + callback, _ := p.String("callback") + wrapper := &responses.JsonWrapper{Subsonic: *payload} + response, err = json.Marshal(wrapper) + response = []byte(fmt.Sprintf("%s(%s)", callback, response)) + default: + w.Header().Set("Content-Type", "application/xml") + response, err = 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 payload.Status == responses.StatusOK { + if log.IsGreaterOrEqualTo(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") + } + } 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 { + 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 new file mode 100644 index 0000000..a83f2f0 --- /dev/null +++ b/server/subsonic/api_suite_test.go @@ -0,0 +1,17 @@ +package subsonic + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "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") +} diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go new file mode 100644 index 0000000..eaecd7c --- /dev/null +++ b/server/subsonic/api_test.go @@ -0,0 +1,127 @@ +package subsonic + +import ( + "encoding/json" + "encoding/xml" + "math" + "net/http" + "net/http/httptest" + "strings" + + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/net/context" +) + +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)) + }) + }) + + 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: gg.P(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("updates status pointer when an error occurs", func() { + pointer := int32(0) + + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + 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)) + }) +}) diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go new file mode 100644 index 0000000..b1e71b1 --- /dev/null +++ b/server/subsonic/bookmarks.go @@ -0,0 +1,208 @@ +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" +) + +func (api *Router) GetBookmarks(r *http.Request) (*responses.Subsonic, error) { + user, _ := request.UserFrom(r.Context()) + + repo := api.ds.MediaFile(r.Context()) + bookmarks, 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{ + Entry: childFromMediaFile(r.Context(), bmk.Item), + Position: bmk.Position, + Username: user.UserName, + Comment: bmk.Comment, + Created: bmk.CreatedAt, + Changed: bmk.UpdatedAt, + } + }) + return response, nil +} + +func (api *Router) CreateBookmark(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + + comment, _ := p.String("comment") + position := p.Int64Or("position", 0) + + repo := api.ds.MediaFile(r.Context()) + err = repo.AddBookmark(id, comment, position) + if err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) DeleteBookmark(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + + repo := api.ds.MediaFile(r.Context()) + err = repo.DeleteBookmark(id) + if err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) { + user, _ := request.UserFrom(r.Context()) + + repo := api.ds.PlayQueue(r.Context()) + pq, err := repo.RetrieveWithMediaFiles(user.ID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + if pq == nil || len(pq.Items) == 0 { + return newResponse(), 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, + Position: pq.Position, + Username: user.UserName, + 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) + + 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 + } + } + + pq := &model.PlayQueue{ + UserID: user.ID, + Current: currentIndex, + Position: position, + ChangedBy: client, + Items: items, + CreatedAt: time.Time{}, + UpdatedAt: time.Time{}, + } + + repo := api.ds.PlayQueue(r.Context()) + err := repo.Store(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(r.Context()) + pq, err := repo.RetrieveWithMediaFiles(user.ID) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + if pq == nil || len(pq.Items) == 0 { + return newResponse(), 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(r.Context()) + err = repo.Store(pq) + if err != nil { + return nil, err + } + return newResponse(), nil +} diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go new file mode 100644 index 0000000..c858454 --- /dev/null +++ b/server/subsonic/browsing.go @@ -0,0 +1,470 @@ +package subsonic + +import ( + "context" + "errors" + "net/http" + "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/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" +) + +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) + folders[i].Name = f.Name + } + response := newResponse() + response.MusicFolders = &responses.MusicFolders{Folders: folders} + return response, nil +} + +func (api *Router) getArtist(r *http.Request, libIds []int, ifModifiedSince time.Time) (model.ArtistIndexes, int64, error) { + ctx := r.Context() + + lastScanStr, err := api.ds.Property(ctx).DefaultGet(consts.LastScanStartTimeKey, "") + if err != nil { + log.Error(ctx, "Error retrieving last scan start time", err) + return nil, 0, err + } + lastScan := time.Now() + if lastScanStr != "" { + lastScan, err = time.Parse(time.RFC3339, lastScanStr) + } + + var indexes model.ArtistIndexes + if lastScan.After(ifModifiedSince) { + indexes, err = api.ds.Artist(ctx).GetIndex(false, libIds, model.RoleAlbumArtist) + if err != nil { + log.Error(ctx, "Error retrieving Indexes", err) + return nil, 0, 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, + } + + 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) + } + 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{}) + + res, err := api.getArtistIndex(r, musicFolderIds, ifModifiedSince) + if err != nil { + return nil, err + } + + response := newResponse() + response.Indexes = res + return response, nil +} + +func (api *Router) GetArtists(r *http.Request) (*responses.Subsonic, error) { + musicFolderIds, _ := selectedMusicFolderIds(r, false) + + res, err := api.getArtistIndexID3(r, musicFolderIds, time.Time{}) + if err != nil { + return nil, err + } + + response := newResponse() + response.Artist = res + return response, nil +} + +func (api *Router) GetMusicDirectory(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, _ := p.String("id") + ctx := r.Context() + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if errors.Is(err, model.ErrNotFound) { + log.Error(r, "Requested ID not found ", "id", id) + return nil, newError(responses.ErrorDataNotFound, "Directory not found") + } + if err != nil { + log.Error(err) + return nil, err + } + + var dir *responses.Directory + + switch v := entity.(type) { + case *model.Artist: + dir, err = api.buildArtistDirectory(ctx, v) + case *model.Album: + dir, err = api.buildAlbumDirectory(ctx, v) + default: + log.Error(r, "Requested ID of invalid type", "id", id, "entity", v) + return nil, newError(responses.ErrorDataNotFound, "Directory not found") + } + + if err != nil { + log.Error(err) + return nil, err + } + + response := newResponse() + response.Directory = dir + return response, nil +} + +func (api *Router) GetArtist(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, _ := p.String("id") + ctx := r.Context() + + 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") + } + if err != nil { + log.Error(ctx, "Error retrieving artist", "id", id, err) + return nil, err + } + + response := newResponse() + response.ArtistWithAlbumsID3, err = api.buildArtist(r, artist) + if err != nil { + log.Error(ctx, "Error retrieving albums by artist", "id", artist.ID, "name", artist.Name, err) + } + return response, err +} + +func (api *Router) GetAlbum(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, _ := p.String("id") + + ctx := r.Context() + + 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") + } + if err != nil { + log.Error(ctx, "Error retrieving album", "id", id, err) + return nil, err + } + + 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 + } + + response := newResponse() + response.AlbumWithSongsID3 = api.buildAlbum(ctx, album, mfs) + return response, nil +} + +func (api *Router) GetAlbumInfo(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + ctx := r.Context() + + if err != nil { + return nil, err + } + + album, err := api.provider.UpdateAlbumInfo(ctx, id) + + if err != nil { + return nil, err + } + + response := newResponse() + response.AlbumInfo = &responses.AlbumInfo{} + response.AlbumInfo.Notes = album.Description + response.AlbumInfo.SmallImageUrl = public.ImageURL(r, album.CoverArtID(), 300) + response.AlbumInfo.MediumImageUrl = public.ImageURL(r, album.CoverArtID(), 600) + response.AlbumInfo.LargeImageUrl = public.ImageURL(r, album.CoverArtID(), 1200) + + response.AlbumInfo.LastFmUrl = album.ExternalUrl + response.AlbumInfo.MusicBrainzID = album.MbzAlbumID + + return response, nil +} + +func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, _ := p.String("id") + ctx := r.Context() + + 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") + } + if err != nil { + log.Error(r, "Error retrieving MediaFile", "id", id, err) + return nil, err + } + + response := newResponse() + 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(ctx).GetAll(model.QueryOptions{Sort: "song_count, album_count, name desc", Order: "desc"}) + if err != nil { + log.Error(r, err) + return nil, err + } + for i, g := range genres { + if g.Name == "" { + genres[i].Name = "<Empty>" + } + } + + response := newResponse() + response.Genres = toGenres(genres) + 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 + base.SmallImageUrl = public.ImageURL(r, artist.CoverArtID(), 300) + base.MediumImageUrl = public.ImageURL(r, artist.CoverArtID(), 600) + base.LargeImageUrl = public.ImageURL(r, 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) + if err != nil { + return nil, err + } + + response := newResponse() + response.ArtistInfo = &responses.ArtistInfo{} + response.ArtistInfo.ArtistInfoBase = *base + + for _, s := range *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) + 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.SimilarArtist = append(response.ArtistInfo2.SimilarArtist, similar) + } + return response, nil +} + +func (api *Router) GetSimilarSongs(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + count := p.IntOr("count", 50) + + songs, err := api.provider.ArtistRadio(ctx, id, count) + if err != nil { + return nil, err + } + + response := newResponse() + response.SimilarSongs = &responses.SimilarSongs{ + Song: slice.MapWithArg(songs, ctx, childFromMediaFile), + } + return response, nil +} + +func (api *Router) GetSimilarSongs2(r *http.Request) (*responses.Subsonic, error) { + res, err := api.GetSimilarSongs(r) + if err != nil { + return nil, err + } + + response := newResponse() + response.SimilarSongs2 = &responses.SimilarSongs2{ + Song: res.SimilarSongs.Song, + } + return response, nil +} + +func (api *Router) GetTopSongs(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + p := req.Params(r) + artist, err := p.String("artist") + if err != nil { + return nil, err + } + count := p.IntOr("count", 50) + + songs, err := api.provider.TopSongs(ctx, artist, count) + if err != nil && !errors.Is(err, model.ErrNotFound) { + return nil, err + } + + response := newResponse() + response.TopSongs = &responses.TopSongs{ + Song: slice.MapWithArg(songs, ctx, childFromMediaFile), + } + return response, nil +} + +func (api *Router) buildArtistDirectory(ctx context.Context, artist *model.Artist) (*responses.Directory, error) { + dir := &responses.Directory{} + dir.Id = artist.ID + dir.Name = artist.Name + dir.PlayCount = artist.PlayCount + if artist.PlayCount > 0 { + dir.Played = artist.PlayDate + } + dir.AlbumCount = getArtistAlbumCount(artist) + dir.UserRating = int32(artist.Rating) + if artist.Starred { + dir.Starred = artist.StarredAt + } + + albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID)) + if err != nil { + return nil, err + } + + dir.Child = slice.MapWithArg(albums, ctx, childFromAlbum) + return dir, nil +} + +func (api *Router) buildArtist(r *http.Request, artist *model.Artist) (*responses.ArtistWithAlbumsID3, error) { + ctx := r.Context() + a := &responses.ArtistWithAlbumsID3{} + a.ArtistID3 = toArtistID3(r, *artist) + + albums, err := api.ds.Album(ctx).GetAll(filter.AlbumsByArtistID(artist.ID)) + if err != nil { + return nil, err + } + + a.Album = slice.MapWithArg(albums, ctx, buildAlbumID3) + 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.Name + dir.Parent = album.AlbumArtistID + dir.PlayCount = album.PlayCount + if album.PlayCount > 0 { + dir.Played = album.PlayDate + } + dir.UserRating = int32(album.Rating) + dir.SongCount = int32(album.SongCount) + dir.CoverArt = album.CoverArtID().String() + if album.Starred { + dir.Starred = album.StarredAt + } + + mfs, err := api.ds.MediaFile(ctx).GetAll(filter.SongsByAlbum(album.ID)) + if err != nil { + return nil, err + } + + dir.Child = slice.MapWithArg(mfs, ctx, childFromMediaFile) + 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) + return dir +} diff --git a/server/subsonic/browsing_test.go b/server/subsonic/browsing_test.go new file mode 100644 index 0000000..b8f510a --- /dev/null +++ b/server/subsonic/browsing_test.go @@ -0,0 +1,160 @@ +package subsonic + +import ( + "context" + "fmt" + "net/http/httptest" + + "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" +) + +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(ctx).(*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(ctx).(*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(ctx).(*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(ctx).(*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()) + }) + }) +}) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go new file mode 100644 index 0000000..8ba4f0f --- /dev/null +++ b/server/subsonic/filter/filters.go @@ -0,0 +1,182 @@ +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 { + filters := []Sqlizer{ + persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artistId}), + } + if conf.Server.Subsonic.ArtistParticipations { + filters = append(filters, + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artistId}), + ) + } + return addDefaultFilters(Options{ + Sort: "max_year", + Filters: Or(filters), + }) +} + +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", + }) +} + +func SongsByRandom(genre string, fromYear, toYear int) Options { + options := Options{ + Sort: "random", + } + ff := And{} + if genre != "" { + ff = append(ff, filterByGenre(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 SongsByArtistTitleWithLyricsFirst(artist, title string) Options { + return addDefaultFilters(Options{ + Sort: "lyrics, updated_at", + Order: "desc", + Max: 1, + Filters: And{ + Eq{"title": title}, + Or{ + persistence.Exists("json_tree(participants, '$.albumartist')", Eq{"value": artist}), + persistence.Exists("json_tree(participants, '$.artist')", Eq{"value": artist}), + }, + }, + }) +} + +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 +} + +func ByGenre(genre string) Options { + return addDefaultFilters(Options{ + Sort: "name", + Filters: filterByGenre(genre), + }) +} + +func filterByGenre(genre string) Sqlizer { + return persistence.Exists(`json_tree(tags, "$.genre")`, And{ + Like{"value": genre}, + NotEq{"atom": nil}, + }) +} + +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/subsonic/helpers.go b/server/subsonic/helpers.go new file mode 100644 index 0000000..f9733bb --- /dev/null +++ b/server/subsonic/helpers.go @@ -0,0 +1,515 @@ +package subsonic + +import ( + "cmp" + "context" + "errors" + "fmt" + "mime" + "net/http" + "slices" + "sort" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "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/number" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +func newResponse() *responses.Subsonic { + return &responses.Subsonic{ + Status: responses.StatusOK, + Version: Version, + Type: consts.AppName, + ServerVersion: consts.Version, + OpenSubsonic: true, + } +} + +type subError struct { + code int32 + messages []interface{} +} + +func newError(code int32, 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 { + msg = responses.ErrorMsg(e.code) + } else { + msg = fmt.Sprintf(e.messages[0].(string), e.messages[1:]...) + } + return msg +} + +func getUser(ctx context.Context) model.User { + user, ok := request.UserFrom(ctx) + if ok { + return user + } + return model.User{} +} + +func sortName(sortName, orderName string) string { + if conf.Server.PreferSortTags { + return cmp.Or( + sortName, + orderName, + ) + } + 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) + } +} + +func toArtist(r *http.Request, a model.Artist) responses.Artist { + artist := responses.Artist{ + Id: a.ID, + Name: a.Name, + UserRating: int32(a.Rating), + CoverArt: a.CoverArtID().String(), + ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), + } + if a.Starred { + 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: a.CoverArtID().String(), + ArtistImageUrl: public.ImageURL(r, a.CoverArtID(), 600), + UserRating: int32(a.Rating), + } + if a.Starred { + 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 { + response[i] = responses.Genre{ + Name: g.Name, + SongCount: int32(g.SongCount), + AlbumCount: int32(g.AlbumCount), + } + } + 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 + } + if plr, ok := request.PlayerFrom(ctx); ok { + bitRate = plr.MaxBitRate + } + return +} + +func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child { + child := responses.Child{} + child.Id = mf.ID + child.Title = mf.FullTitle() + child.IsDir = false + child.Parent = mf.AlbumID + child.Album = mf.Album + child.Year = int32(mf.Year) + child.Artist = mf.Artist + child.Genre = mf.Genre + child.Track = int32(mf.TrackNumber) + child.Duration = int32(mf.Duration) + child.Size = mf.Size + child.Suffix = mf.Suffix + child.BitRate = int32(mf.BitRate) + child.CoverArt = mf.CoverArtID().String() + child.ContentType = mf.ContentType() + player, ok := request.PlayerFrom(ctx) + if ok && player.ReportRealPath { + child.Path = mf.AbsolutePath() + } else { + child.Path = fakePath(mf) + } + child.DiscNumber = int32(mf.DiscNumber) + child.Created = &mf.BirthTime + child.AlbumId = mf.AlbumID + child.ArtistId = mf.ArtistID + child.Type = "music" + child.PlayCount = mf.PlayCount + if mf.Starred { + child.Starred = mf.StarredAt + } + child.UserRating = int32(mf.Rating) + + format, _ := getTranscoding(ctx) + if mf.Suffix != "" && format != "" && mf.Suffix != format { + child.TranscodedSuffix = format + 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, _ := request.PlayerFrom(ctx) + if strings.Contains(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(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(mf.BitDepth) + child.Genres = toItemGenres(mf.Genres) + child.Moods = mf.Tags.Values(model.TagMood) + 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) + 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.Album))) + if mf.DiscNumber != 0 { + builder.WriteString(fmt.Sprintf("%02d-", mf.DiscNumber)) + } + if mf.TrackNumber != 0 { + builder.WriteString(fmt.Sprintf("%02d - ", mf.TrackNumber)) + } + builder.WriteString(fmt.Sprintf("%s.%s", sanitizeSlashes(mf.FullTitle()), mf.Suffix)) + return builder.String() +} + +func sanitizeSlashes(target string) string { + return strings.ReplaceAll(target, "/", "_") +} + +func childFromAlbum(ctx context.Context, al model.Album) responses.Child { + child := responses.Child{} + child.Id = al.ID + child.IsDir = true + 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.Genre = al.Genre + 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.PlayCount = al.PlayCount + child.UserRating = int32(al.Rating) + child.OpenSubsonicChild = osChildFromAlbum(ctx, al) + 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 + } + 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.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 + for num, title := range a.Discs { + discTitles = append(discTitles, responses.DiscTitle{Disc: int32(num), Title: title}) + } + 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.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 + dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) + dir.Genre = album.Genre + if !album.CreatedAt.IsZero() { + dir.Created = &album.CreatedAt + } + 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) + 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) + if len(album.Tags.Values(model.TagAlbumVersion)) > 0 { + dir.Version = album.Tags.Values(model.TagAlbumVersion)[0] + } + + return &dir +} + +func mapExplicitStatus(explicitStatus string) string { + switch explicitStatus { + case "c": + return "clean" + case "e": + return "explicit" + } + return "" +} + +func buildStructuredLyric(mf *model.MediaFile, lyrics model.Lyrics) responses.StructuredLyric { + lines := make([]responses.Line, len(lyrics.Line)) + + for i, line := range lyrics.Line { + lines[i] = responses.Line{ + Start: line.Start, + Value: line.Value, + } + } + + structured := responses.StructuredLyric{ + DisplayArtist: lyrics.DisplayArtist, + DisplayTitle: lyrics.DisplayTitle, + Lang: lyrics.Lang, + Line: lines, + Offset: lyrics.Offset, + Synced: lyrics.Synced, + } + + if structured.DisplayArtist == "" { + structured.DisplayArtist = mf.Artist + } + if structured.DisplayTitle == "" { + structured.DisplayTitle = mf.Title + } + + return structured +} + +func buildLyricsList(mf *model.MediaFile, lyricsList model.LyricList) *responses.LyricsList { + lyricList := make(responses.StructuredLyrics, len(lyricsList)) + + for i, lyrics := range lyricsList { + lyricList[i] = buildStructuredLyric(mf, lyrics) + } + + res := &responses.LyricsList{ + StructuredLyrics: lyricList, + } + return res +} + +// 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 +} diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go new file mode 100644 index 0000000..a6508d4 --- /dev/null +++ b/server/subsonic/helpers_test.go @@ -0,0 +1,275 @@ +package subsonic + +import ( + "context" + "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/navidrome/navidrome/utils/req" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("helpers", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + Describe("fakePath", func() { + var mf model.MediaFile + BeforeEach(func() { + mf.AlbumArtist = "Brock Berrigan" + mf.Album = "Point Pleasant" + mf.Title = "Split Decision" + mf.Suffix = "flac" + }) + When("TrackNumber is not available", func() { + It("does not add any number to the filename", func() { + Expect(fakePath(mf)).To(Equal("Brock Berrigan/Point Pleasant/Split Decision.flac")) + }) + }) + When("TrackNumber is available", func() { + It("adds the trackNumber to the path", func() { + mf.TrackNumber = 4 + 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() { + 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 for a single disc", func() { + album := model.Album{ + Discs: map[int]string{ + 1: "Special Edition", + }, + } + Expect(buildDiscSubtitles(album)).To(Equal([]responses.DiscTitle{{Disc: 1, Title: "Special Edition"}})) + }) + + It("should return correct disc titles when album has discs with valid disc numbers", func() { + album := model.Album{ + Discs: map[int]string{ + 1: "Disc 1", + 2: "Disc 2", + }, + } + expected := []responses.DiscTitle{ + {Disc: 1, Title: "Disc 1"}, + {Disc: 2, Title: "Disc 2"}, + } + Expect(buildDiscSubtitles(album)).To(Equal(expected)) + }) + }) + + 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))) + }) + }) + + 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 + }) + }) + }) +}) diff --git a/server/subsonic/jukebox.go b/server/subsonic/jukebox.go new file mode 100644 index 0000000..c4bc643 --- /dev/null +++ b/server/subsonic/jukebox.go @@ -0,0 +1,136 @@ +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 new file mode 100644 index 0000000..c9dd649 --- /dev/null +++ b/server/subsonic/library_scanning.go @@ -0,0 +1,103 @@ +package subsonic + +import ( + "fmt" + "net/http" + "slices" + "time" + + "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" +) + +func (api *Router) GetScanStatus(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + status, err := api.scanner.Status(ctx) + if err != nil { + log.Error(ctx, "Error retrieving Scanner status", err) + return nil, newError(responses.ErrorGeneric, "Internal Error") + } + response := newResponse() + response.ScanStatus = &responses.ScanStatus{ + Scanning: status.Scanning, + Count: int64(status.Count), + FolderCount: int64(status.FolderCount), + LastScan: &status.LastScan, + Error: status.LastError, + ScanType: status.ScanType, + ElapsedTime: int64(status.ElapsedTime), + } + return response, nil +} + +func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + loggedUser, ok := request.UserFrom(ctx) + if !ok { + return nil, newError(responses.ErrorGeneric, "Internal error") + } + + if !loggedUser.IsAdmin { + return nil, newError(responses.ErrorAuthorizationFail) + } + + p := req.Params(r) + fullScan := p.BoolOr("fullScan", false) + + // Parse optional target parameters for selective scanning + var targets []model.ScanTarget + if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 { + 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(ctx).GetUserLibraries(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(ctx).GetAll() + if err != nil { + return nil, newError(responses.ErrorGeneric, "Internal error") + } + if len(allLibs) == 1 { + targets = nil // This will trigger ScanAll below + } + } + } + + go func() { + 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) + } + + if err != nil { + log.Error(ctx, "Error scanning", err) + return + } + log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start)) + }() + + return api.GetScanStatus(r) +} diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go new file mode 100644 index 0000000..d8eba29 --- /dev/null +++ b/server/subsonic/library_scanning_test.go @@ -0,0 +1,396 @@ +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("requires admin authentication", func() { + // Create non-admin user + ctx := request.WithUser(context.Background(), model.User{ + ID: "user-id", + IsAdmin: false, + }) + + // Create request + r := httptest.NewRequest("GET", "/rest/startScan", nil) + r = r.WithContext(ctx) + + // Call endpoint + response, err := api.StartScan(r) + + // Should return authorization 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.ErrorAuthorizationFail)) + }) + + 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("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("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("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("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("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("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("")) + }) + }) + + 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/media_annotation.go b/server/subsonic/media_annotation.go new file mode 100644 index 0000000..39bc83f --- /dev/null +++ b/server/subsonic/media_annotation.go @@ -0,0 +1,222 @@ +package subsonic + +import ( + "context" + "fmt" + "net/http" + "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" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + rating, err := p.Int("rating") + if err != nil { + return nil, err + } + + log.Debug(r, "Setting rating", "rating", rating, "id", id) + err = api.setRating(r.Context(), id, rating) + if err != nil { + log.Error(r, err) + return nil, err + } + + return newResponse(), nil +} + +func (api *Router) setRating(ctx context.Context, id string, rating int) error { + var repo model.AnnotatedRepository + var resource string + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + return err + } + switch entity.(type) { + case *model.Artist: + repo = api.ds.Artist(ctx) + resource = "artist" + case *model.Album: + repo = api.ds.Album(ctx) + resource = "album" + default: + repo = api.ds.MediaFile(ctx) + resource = "song" + } + err = repo.SetRating(rating, id) + if err != nil { + return err + } + event := &events.RefreshResource{} + api.broker.SendMessage(ctx, event.With(resource, id)) + return nil +} + +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") + if len(ids)+len(albumIds)+len(artistIds) == 0 { + return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") + } + ids = append(ids, albumIds...) + ids = append(ids, artistIds...) + + err := api.setStar(r.Context(), true, ids...) + if err != nil { + return nil, err + } + + return newResponse(), nil +} + +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") + if len(ids)+len(albumIds)+len(artistIds) == 0 { + return nil, newError(responses.ErrorMissingParameter, "Required id parameter is missing") + } + ids = append(ids, albumIds...) + ids = append(ids, artistIds...) + + err := api.setStar(r.Context(), false, ids...) + if err != nil { + return nil, err + } + + return newResponse(), nil +} + +func (api *Router) setStar(ctx context.Context, star bool, ids ...string) error { + if len(ids) == 0 { + return nil + } + log.Debug(ctx, "Changing starred", "ids", ids, "starred", star) + if len(ids) == 0 { + log.Warn(ctx, "Cannot star/unstar an empty list of ids") + return nil + } + event := &events.RefreshResource{} + err := api.ds.WithTxImmediate(func(tx model.DataStore) error { + for _, id := range ids { + exist, err := tx.Album(ctx).Exists(id) + if err != nil { + return err + } + 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 err != nil { + log.Error(ctx, err) + return err + } + return nil +} + +func (api *Router) Scrobble(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + ids, err := p.Strings("id") + if err != nil { + return nil, err + } + times, _ := p.Times("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) + ctx := r.Context() + + if submission { + err := api.scrobblerSubmit(ctx, ids, times) + if err != nil { + log.Error(ctx, "Error registering scrobbles", "ids", ids, "times", times, err) + } + } else { + err := api.scrobblerNowPlaying(ctx, ids[0], position) + if err != nil { + log.Error(ctx, "Error setting NowPlaying", "id", ids[0], err) + } + } + + return newResponse(), nil +} + +func (api *Router) scrobblerSubmit(ctx context.Context, ids []string, times []time.Time) error { + var submissions []scrobbler.Submission + log.Debug(ctx, "Scrobbling tracks", "ids", ids, "times", times) + for i, id := range ids { + var t time.Time + if len(times) > 0 { + t = times[i] + } else { + t = time.Now() + } + submissions = append(submissions, scrobbler.Submission{TrackID: id, Timestamp: t}) + } + + return api.scrobbler.Submit(ctx, submissions) +} + +func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, position int) error { + mf, err := api.ds.MediaFile(ctx).Get(trackId) + if err != nil { + return err + } + if mf == nil { + return fmt.Errorf(`ID "%s" not found`, trackId) + } + + player, _ := request.PlayerFrom(ctx) + username, _ := request.UsernameFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + + log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) + err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) + return err +} diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go new file mode 100644 index 0000000..6f09f53 --- /dev/null +++ b/server/subsonic/media_annotation_test.go @@ -0,0 +1,145 @@ +package subsonic + +import ( + "context" + "fmt" + "net/http" + "time" + + "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/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MediaAnnotationController", func() { + var router *Router + var ds model.DataStore + var playTracker *fakePlayTracker + var eventBroker *fakeEventBroker + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + ds = &tests.MockDataStore{} + playTracker = &fakePlayTracker{} + eventBroker = &fakeEventBroker{} + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil) + }) + + Describe("Scrobble", func() { + It("submit all scrobbles with only the id", func() { + submissionTime := time.Now() + r := newGetRequest("id=12", "id=34") + + _, err := router.Scrobble(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.Submissions).To(HaveLen(2)) + Expect(playTracker.Submissions[0].Timestamp).To(BeTemporally(">", submissionTime)) + Expect(playTracker.Submissions[0].TrackID).To(Equal("12")) + Expect(playTracker.Submissions[1].Timestamp).To(BeTemporally(">", submissionTime)) + Expect(playTracker.Submissions[1].TrackID).To(Equal("34")) + }) + + It("submit all scrobbles with respective times", func() { + time1 := time.Now().Add(-20 * time.Minute) + t1 := time1.UnixMilli() + time2 := time.Now().Add(-10 * time.Minute) + t2 := time2.UnixMilli() + r := newGetRequest("id=12", "id=34", fmt.Sprintf("time=%d", t1), fmt.Sprintf("time=%d", t2)) + + _, err := router.Scrobble(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.Submissions).To(HaveLen(2)) + Expect(playTracker.Submissions[0].Timestamp).To(BeTemporally("~", time1)) + Expect(playTracker.Submissions[0].TrackID).To(Equal("12")) + Expect(playTracker.Submissions[1].Timestamp).To(BeTemporally("~", time2)) + Expect(playTracker.Submissions[1].TrackID).To(Equal("34")) + }) + + It("checks if number of ids match number of times", func() { + r := newGetRequest("id=12", "id=34", "time=1111") + + _, err := router.Scrobble(r) + + Expect(err).To(HaveOccurred()) + Expect(playTracker.Submissions).To(BeEmpty()) + }) + + Context("submission=false", func() { + var req *http.Request + BeforeEach(func() { + _ = 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) + }) + + It("does not scrobble", func() { + _, err := router.Scrobble(req) + + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.Submissions).To(BeEmpty()) + }) + + It("registers a NowPlaying", func() { + _, err := router.Scrobble(req) + + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.Playing).To(HaveLen(1)) + Expect(playTracker.Playing).To(HaveKey("player-1")) + }) + }) + }) +}) + +type fakePlayTracker struct { + Submissions []scrobbler.Submission + Playing map[string]string + Error error +} + +func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { + if f.Error != nil { + return f.Error + } + if f.Playing == nil { + f.Playing = make(map[string]string) + } + f.Playing[playerId] = trackId + return nil +} + +func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { + return nil, f.Error +} + +func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Submission) error { + if f.Error != nil { + return f.Error + } + f.Submissions = append(f.Submissions, submissions...) + return nil +} + +var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil) + +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/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go new file mode 100644 index 0000000..a72e486 --- /dev/null +++ b/server/subsonic/media_retrieval.go @@ -0,0 +1,153 @@ +package subsonic + +import ( + "context" + "errors" + "io" + "net/http" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/server/subsonic/filter" + "github.com/navidrome/navidrome/server/subsonic/responses" + "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") + if err != nil { + return nil, err + } + ctx := r.Context() + u, err := api.ds.User(ctx).FindByUsername(username) + if err != nil { + return nil, err + } + if u.Email == "" { + 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) + return nil, nil +} + +func (api *Router) getPlaceHolderAvatar(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + f, err := resources.FS().Open(consts.PlaceholderAvatar) + if err != nil { + log.Error(r, "Image not found", err) + return nil, newError(responses.ErrorDataNotFound, "Avatar image not found") + } + defer f.Close() + _, _ = io.Copy(w, f) + + return nil, nil +} + +func (api *Router) GetCoverArt(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + // If context is already canceled, discard request without further processing + if r.Context().Err() != nil { + return nil, nil //nolint:nilerr + } + + 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) + + imgReader, lastUpdate, err := api.artwork.GetOrPlaceholder(ctx, id, size, square) + switch { + case errors.Is(err, context.Canceled): + return nil, nil + case errors.Is(err, model.ErrNotFound): + log.Warn(r, "Couldn't find coverArt", "id", id, err) + return nil, newError(responses.ErrorDataNotFound, "Artwork not found") + case err != nil: + log.Error(r, "Error retrieving coverArt", "id", id, err) + return nil, err + } + + 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) + } + + return nil, err +} + +func (api *Router) GetLyrics(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + artist, _ := p.String("artist") + title, _ := p.String("title") + response := newResponse() + lyricsResponse := responses.Lyrics{} + response.Lyrics = &lyricsResponse + mediaFiles, err := api.ds.MediaFile(r.Context()).GetAll(filter.SongsByArtistTitleWithLyricsFirst(artist, title)) + + if err != nil { + return nil, err + } + + if len(mediaFiles) == 0 { + return response, nil + } + + structuredLyrics, err := lyrics.GetLyrics(r.Context(), &mediaFiles[0]) + if err != nil { + return nil, err + } + + if len(structuredLyrics) == 0 { + return response, nil + } + + lyricsResponse.Artist = artist + lyricsResponse.Title = title + + lyricsText := "" + for _, line := range structuredLyrics[0].Line { + lyricsText += line.Value + "\n" + } + + lyricsResponse.Value = lyricsText + + 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(r.Context()).Get(id) + if err != nil { + return nil, err + } + + structuredLyrics, err := lyrics.GetLyrics(r.Context(), mediaFile) + if err != nil { + return nil, err + } + + response := newResponse() + response.LyricsList = buildLyricsList(mediaFile, structuredLyrics) + + return response, nil +} diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go new file mode 100644 index 0000000..351b4e5 --- /dev/null +++ b/server/subsonic/media_retrieval_test.go @@ -0,0 +1,379 @@ +package subsonic + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "io" + "net/http/httptest" + "slices" + "time" + + "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/server/subsonic/responses" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("MediaRetrievalController", func() { + var router *Router + var ds model.DataStore + mockRepo := &mockedMediaFile{MockMediaFileRepo: tests.MockMediaFileRepo{}} + var artwork *fakeArtwork + var w *httptest.ResponseRecorder + + BeforeEach(func() { + ds = &tests.MockDataStore{ + MockedMediaFile: mockRepo, + } + artwork = &fakeArtwork{data: "image data"} + router = New(ds, artwork, nil, nil, 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") + _, err := router.GetCoverArt(w, r) + + Expect(err).ToNot(HaveOccurred()) + 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 + _, err := router.GetCoverArt(w, r) + + Expect(err).To(BeNil()) + Expect(artwork.recvId).To(BeEmpty()) + Expect(w.Body.String()).To(Equal(artwork.data)) + }) + + It("should fail when the file is not found", func() { + artwork.err = model.ErrNotFound + r := newGetRequest("id=34", "size=128", "square=true") + _, err := router.GetCoverArt(w, r) + + Expect(err).To(MatchError("Artwork not found")) + }) + + It("should fail when there is an unknown error", func() { + artwork.err = errors.New("weird error") + r := newGetRequest("id=34", "size=128") + _, err := router.GetCoverArt(w, r) + + 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() { + // Create a request + ctx, cancel := context.WithCancel(context.Background()) + r := newGetRequest("id=34", "size=128", "square=true") + r = r.WithContext(ctx) + cancel() // Cancel the context before the call + + // Call the GetCoverArt method + _, err := router.GetCoverArt(w, r) + + // Expect no error and no call to the artwork service + 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() { + // Create a request with a context that will be cancelled + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Ensure the context is cancelled after the test (best practices) + r := newGetRequest("id=34", "size=128", "square=true") + r = r.WithContext(ctx) + artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service + + // Call the GetCoverArt method + _, err := router.GetCoverArt(w, r) + + // Expect no error and the service to have been called + 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("GetLyrics", func() { + It("should return data for given artist & title", func() { + r := newGetRequest("artist=Rick+Astley", "title=Never+Gonna+Give+You+Up") + lyrics, _ := model.ToLyrics("eng", "[00:18.80]We're no strangers to love\n[00:22.80]You know the rules and so do I") + lyricsJson, err := json.Marshal(model.LyricList{ + *lyrics, + }) + Expect(err).ToNot(HaveOccurred()) + + baseTime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) + mockRepo.SetData(model.MediaFiles{ + { + ID: "2", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + UpdatedAt: baseTime.Add(2 * time.Hour), // No lyrics, newer + }, + { + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: string(lyricsJson), + UpdatedAt: baseTime.Add(1 * time.Hour), // Has lyrics, older + }, + { + ID: "3", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + Lyrics: "[]", + UpdatedAt: baseTime.Add(3 * time.Hour), // No lyrics, newest + }, + }) + 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")) + }) + 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()) + 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") + mockRepo.SetData(model.MediaFiles{ + { + Path: "tests/fixtures/test.mp3", + ID: "1", + Artist: "Rick Astley", + Title: "Never Gonna Give You Up", + }, + { + Path: "tests/fixtures/test.mp3", + ID: "2", + 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")) + }) + }) + + Describe("GetLyricsBySongId", 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" + 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.Lang).To(Equal(expectedLyric.Lang)) + Expect(realLyric.Synced).To(Equal(expectedLyric.Synced)) + + 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)) + } + } + } + } + + It("should return mixed lyrics", func() { + r := newGetRequest("id=1") + synced, _ := model.ToLyrics("eng", syncedLyrics) + unsynced, _ := model.ToLyrics("xxx", unsyncedLyrics) + 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") + synced, _ := model.ToLyrics("eng", metadata+"\n"+syncedLyrics) + 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()) + + offset := int64(-100) + 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: &offset, + }, + }, + }) + }) + }) +}) + +type fakeArtwork struct { + artwork.Artwork + data string + err error + ctxCancelFunc func() + recvId string + recvSize int + recvSquare bool +} + +func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { + if c.err != nil { + return nil, time.Time{}, c.err + } + c.recvId = id + c.recvSize = size + c.recvSquare = square + if c.ctxCancelFunc != nil { + c.ctxCancelFunc() // Simulate context cancellation + return nil, time.Time{}, context.Canceled + } + return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil +} + +type mockedMediaFile struct { + tests.MockMediaFileRepo +} + +func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, error) { + data, err := m.MockMediaFileRepo.GetAll(opts...) + if err != nil { + return nil, err + } + if len(opts) == 0 || opts[0].Sort != "lyrics, updated_at" { + return data, nil + } + + // Hardcoded support for lyrics sorting + result := slices.Clone(data) + // Sort by presence of lyrics, then by updated_at. Respect the order specified in opts. + 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 +} diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go new file mode 100644 index 0000000..d984bac --- /dev/null +++ b/server/subsonic/middlewares.go @@ -0,0 +1,272 @@ +package subsonic + +import ( + "cmp" + "context" + "crypto/md5" + "encoding/hex" + "errors" + "fmt" + "net" + "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/gg" + "github.com/navidrome/navidrome/utils/req" +) + +func postFormToQueryParams(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + sendError(w, r, newError(responses.ErrorGeneric, err.Error())) + } + var parts []string + for key, values := range r.Form { + for _, v := range values { + parts = append(parts, url.QueryEscape(key)+"="+url.QueryEscape(v)) + } + } + r.URL.RawQuery = strings.Join(parts, "&") + + next.ServeHTTP(w, r) + }) +} + +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 + + username, _ := fromInternalOrProxyAuth(r) + if username != "" { + requiredParameters = []string{"v", "c"} + } else { + requiredParameters = []string{"u", "v", "c"} + } + + p := req.Params(r) + for _, param := range requiredParameters { + if _, err := p.String(param); err != nil { + log.Warn(r, err) + sendError(w, r, err) + return + } + } + + if username == "" { + username, _ = p.String("u") + } + client, _ := p.String("c") + version, _ := p.String("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)) + }) +} + +func authenticate(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 := r.Context() + + var usr *model.User + var err error + + username, isInternalAuth := fromInternalOrProxyAuth(r) + if username != "" { + authType := If(isInternalAuth, "internal", "reverse-proxy") + usr, err = ds.User(ctx).FindByUsername(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) + } + } else { + p := req.Params(r) + username, _ := p.String("u") + pass, _ := p.String("p") + token, _ := p.String("t") + salt, _ := p.String("s") + jwt, _ := p.String("jwt") + + usr, err = ds.User(ctx).FindByUsernameWithPassword(username) + if errors.Is(err, context.Canceled) { + log.Debug(ctx, "API: Request canceled when authenticating", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + return + } + switch { + case errors.Is(err, model.ErrNotFound): + log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + case err != nil: + log.Error(ctx, "API: Error authenticating username", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + default: + err = validateCredentials(usr, pass, token, salt, jwt) + if err != nil { + log.Warn(ctx, "API: Invalid login", "auth", "subsonic", "username", username, "remoteAddr", r.RemoteAddr, err) + } + } + } + + if err != nil { + sendError(w, r, newError(responses.ErrorAuthenticationFail)) + return + } + + ctx = request.WithUser(ctx, *usr) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +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["sub"] == user.UserName + case pass != "": + 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 +} + +func getPlayer(players core.Players) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + userName, _ := request.UsernameFrom(ctx) + client, _ := request.ClientFrom(ctx) + playerId := playerIDFromCookie(r, userName) + ip, _, _ := net.SplitHostPort(r.RemoteAddr) + userAgent := canonicalUserAgent(r) + player, trc, err := players.Register(ctx, playerId, client, userAgent, ip) + if err != nil { + log.Error(ctx, "Could not register player", "username", userName, "client", client, err) + } else { + ctx = request.WithPlayer(ctx, *player) + if trc != nil { + ctx = request.WithTranscoding(ctx, *trc) + } + r = r.WithContext(ctx) + + cookie := &http.Cookie{ + Name: playerIDCookieName(userName), + Value: player.ID, + MaxAge: consts.CookieExpiry, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + Path: cmp.Or(conf.Server.BasePath, "/"), + } + http.SetCookie(w, cookie) + } + + next.ServeHTTP(w, r) + }) + } +} + +func canonicalUserAgent(r *http.Request) string { + u := ua.Parse(r.Header.Get("user-agent")) + userAgent := u.Name + if u.OS != "" { + userAgent = userAgent + "/" + u.OS + } + return userAgent +} + +func playerIDFromCookie(r *http.Request, userName string) string { + cookieName := playerIDCookieName(userName) + var playerId string + if c, err := r.Cookie(cookieName); err == nil { + playerId = c.Value + log.Trace(r, "playerId found in cookies", "playerId", playerId) + } + return playerId +} + +func playerIDCookieName(userName string) string { + cookieName := fmt.Sprintf("nd-player-%x", userName) + return cookieName +} + +const subsonicErrorPointer = "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 new file mode 100644 index 0000000..aba14a0 --- /dev/null +++ b/server/subsonic/middlewares_test.go @@ -0,0 +1,501 @@ +package subsonic + +import ( + "context" + "crypto/md5" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "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/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 newGetRequest(queryParams ...string) *http.Request { + r := httptest.NewRequest("GET", "/ping?"+strings.Join(queryParams, "&"), nil) + ctx := r.Context() + return r.WithContext(log.NewContext(ctx)) +} + +func newPostRequest(queryParam string, formFields ...string) *http.Request { + r, err := http.NewRequest("POST", "/ping?"+queryParam, strings.NewReader(strings.Join(formFields, "&"))) + if err != nil { + panic(err) + } + r.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") + ctx := r.Context() + return r.WithContext(log.NewContext(ctx)) +} + +var _ = Describe("Middlewares", func() { + var next *mockHandler + var w *httptest.ResponseRecorder + var ds model.DataStore + + BeforeEach(func() { + next = &mockHandler{} + w = httptest.NewRecorder() + ds = &tests.MockDataStore{} + }) + + Describe("ParsePostForm", func() { + It("converts any filed in a x-www-form-urlencoded POST into query params", func() { + r := newPostRequest("a=abc", "u=user", "v=1.15", "c=test") + cp := postFormToQueryParams(next) + cp.ServeHTTP(w, r) + + Expect(next.req.URL.Query().Get("a")).To(Equal("abc")) + Expect(next.req.URL.Query().Get("u")).To(Equal("user")) + Expect(next.req.URL.Query().Get("v")).To(Equal("1.15")) + Expect(next.req.URL.Query().Get("c")).To(Equal("test")) + }) + It("adds repeated params", func() { + r := newPostRequest("a=abc", "id=1", "id=2") + cp := postFormToQueryParams(next) + cp.ServeHTTP(w, r) + + Expect(next.req.URL.Query().Get("a")).To(Equal("abc")) + Expect(next.req.URL.Query()["id"]).To(ConsistOf("1", "2")) + }) + It("overrides query params with same key", func() { + r := newPostRequest("a=query", "a=body") + cp := postFormToQueryParams(next) + cp.ServeHTTP(w, r) + + Expect(next.req.URL.Query().Get("a")).To(Equal("body")) + }) + }) + + Describe("CheckParams", func() { + It("passes when all required params are available (subsonicauth case)", func() { + r := newGetRequest("u=user", "v=1.15", "c=test") + 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("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("fails when user is missing", func() { + r := newGetRequest("v=1.15", "c=test") + cp := checkRequiredParameters(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="10"`)) + Expect(next.called).To(BeFalse()) + }) + + It("fails when version is missing", func() { + r := newGetRequest("u=user", "c=test") + cp := checkRequiredParameters(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="10"`)) + Expect(next.called).To(BeFalse()) + }) + + It("fails when client is missing", func() { + r := newGetRequest("u=user", "v=1.15") + cp := checkRequiredParameters(next) + cp.ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="10"`)) + Expect(next.called).To(BeFalse()) + }) + }) + + Describe("Authenticate", func() { + BeforeEach(func() { + 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()) + }) + }) + }) + + Describe("GetPlayer", func() { + var mockedPlayers *mockPlayers + var r *http.Request + BeforeEach(func() { + mockedPlayers = &mockPlayers{} + r = newGetRequest() + ctx := request.WithUsername(r.Context(), "someone") + ctx = request.WithClient(ctx, "client") + r = r.WithContext(ctx) + }) + + It("returns a new player in the cookies when none is specified", func() { + gp := getPlayer(mockedPlayers)(next) + gp.ServeHTTP(w, r) + + cookieStr := w.Header().Get("Set-Cookie") + Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone"))) + }) + + It("does not add the cookie if there was an error", func() { + ctx := request.WithClient(r.Context(), "error") + r = r.WithContext(ctx) + + gp := getPlayer(mockedPlayers)(next) + gp.ServeHTTP(w, r) + + cookieStr := w.Header().Get("Set-Cookie") + Expect(cookieStr).To(BeEmpty()) + }) + + Context("PlayerId specified in Cookies", func() { + BeforeEach(func() { + cookie := &http.Cookie{ + Name: playerIDCookieName("someone"), + Value: "123", + MaxAge: consts.CookieExpiry, + } + r.AddCookie(cookie) + + gp := getPlayer(mockedPlayers)(next) + gp.ServeHTTP(w, r) + }) + + It("stores the player in the context", func() { + Expect(next.called).To(BeTrue()) + player, _ := request.PlayerFrom(next.req.Context()) + Expect(player.ID).To(Equal("123")) + _, ok := request.TranscodingFrom(next.req.Context()) + Expect(ok).To(BeFalse()) + }) + + It("returns the playerId in the cookie", func() { + cookieStr := w.Header().Get("Set-Cookie") + Expect(cookieStr).To(ContainSubstring(playerIDCookieName("someone") + "=123")) + }) + }) + + Context("Player has transcoding configured", func() { + BeforeEach(func() { + cookie := &http.Cookie{ + Name: playerIDCookieName("someone"), + Value: "123", + MaxAge: consts.CookieExpiry, + } + r.AddCookie(cookie) + mockedPlayers.transcoding = &model.Transcoding{ID: "12"} + gp := getPlayer(mockedPlayers)(next) + gp.ServeHTTP(w, r) + }) + + It("stores the player in the context", func() { + player, _ := request.PlayerFrom(next.req.Context()) + Expect(player.ID).To(Equal("123")) + transcoding, _ := request.TranscodingFrom(next.req.Context()) + Expect(transcoding.ID).To(Equal("12")) + }) + }) + }) + + Describe("validateCredentials", func() { + var usr *model.User + + BeforeEach(func() { + ur := ds.User(context.TODO()) + _ = ur.Put(&model.User{ + UserName: "admin", + NewPassword: "wordpass", + }) + + var err error + usr, err = ur.FindByUsernameWithPassword("admin") + if err != nil { + panic(err) + } + }) + + Context("Plaintext password", func() { + It("authenticates with plaintext password ", func() { + err := validateCredentials(usr, "wordpass", "", "", "") + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails authentication with wrong password", func() { + err := validateCredentials(usr, "INVALID", "", "", "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Context("Encoded password", func() { + It("authenticates with simple encoded password ", func() { + err := validateCredentials(usr, "enc:776f726470617373", "", "", "") + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Context("Token based authentication", func() { + It("authenticates with token based authentication", func() { + err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "retnlmjetrymazgkt", "") + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails if salt is missing", func() { + err := validateCredentials(usr, "", "23b342970e25c7928831c3317edd0b67", "", "") + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + + Context("JWT based authentication", func() { + var usr *model.User + var validToken string + + BeforeEach(func() { + conf.Server.SessionTimeout = time.Minute + auth.Init(ds) + + usr = &model.User{UserName: "admin"} + var err error + validToken, err = auth.CreateToken(usr) + if err != nil { + panic(err) + } + }) + + It("authenticates with JWT token based authentication", func() { + err := validateCredentials(usr, "", "", "", validToken) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("fails if JWT token is invalid", func() { + err := validateCredentials(usr, "", "", "", "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) + Expect(err).To(MatchError(model.ErrInvalidAuth)) + }) + }) + }) +}) + +type mockHandler struct { + req *http.Request + called bool +} + +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 +} + +func (mp *mockPlayers) Get(ctx context.Context, playerId string) (*model.Player, error) { + return &model.Player{ID: playerId}, nil +} + +func (mp *mockPlayers) Register(ctx context.Context, id, client, typ, ip string) (*model.Player, *model.Transcoding, error) { + if client == "error" { + return nil, nil, errors.New(client) + } + return &model.Player{ID: id}, mp.transcoding, nil +} diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go new file mode 100644 index 0000000..a364651 --- /dev/null +++ b/server/subsonic/opensubsonic.go @@ -0,0 +1,18 @@ +package subsonic + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/subsonic/responses" +) + +func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { + response := newResponse() + response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{ + {Name: "transcodeOffset", Versions: []int32{1}}, + {Name: "formPost", Versions: []int32{1}}, + {Name: "songLyrics", Versions: []int32{1}}, + {Name: "indexBasedQueue", Versions: []int32{1}}, + } + return response, nil +} diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go new file mode 100644 index 0000000..58dca68 --- /dev/null +++ b/server/subsonic/opensubsonic_test.go @@ -0,0 +1,45 @@ +package subsonic_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("GetOpenSubsonicExtensions", func() { + var ( + router *subsonic.Router + w *httptest.ResponseRecorder + r *http.Request + ) + + BeforeEach(func() { + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) + }) + + It("should return the correct OpenSubsonicExtensions", 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(4), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + )) + }) +}) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go new file mode 100644 index 0000000..23fac68 --- /dev/null +++ b/server/subsonic/playlists.go @@ -0,0 +1,172 @@ +package subsonic + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" + "github.com/navidrome/navidrome/utils/slice" +) + +func (api *Router) GetPlaylists(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + 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.Map(allPls, api.buildPlaylist), + } + 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") + if err != nil { + return nil, err + } + return api.getPlaylist(ctx, id) +} + +func (api *Router) getPlaylist(ctx context.Context, id string) (*responses.Subsonic, error) { + pls, err := api.ds.Playlist(ctx).GetWithTracks(id, true, false) + if errors.Is(err, model.ErrNotFound) { + log.Error(ctx, err.Error(), "id", id) + return nil, newError(responses.ErrorDataNotFound, "playlist not found") + } + if err != nil { + log.Error(ctx, err) + return nil, err + } + + response := newResponse() + response.Playlist = &responses.PlaylistWithSongs{ + Playlist: api.buildPlaylist(*pls), + } + response.Playlist.Entry = slice.MapWithArg(pls.MediaFiles(), ctx, childFromMediaFile) + return response, nil +} + +func (api *Router) create(ctx context.Context, playlistId, name string, ids []string) (string, error) { + err := api.ds.WithTxImmediate(func(tx model.DataStore) error { + owner := getUser(ctx) + var pls *model.Playlist + var err error + + if playlistId != "" { + pls, err = tx.Playlist(ctx).Get(playlistId) + if err != nil { + return err + } + if owner.ID != pls.OwnerID { + return model.ErrNotAuthorized + } + } else { + pls = &model.Playlist{Name: name} + pls.OwnerID = owner.ID + } + pls.Tracks = nil + pls.AddMediaFilesByID(ids) + + err = tx.Playlist(ctx).Put(pls) + playlistId = pls.ID + return err + }) + return playlistId, err +} + +func (api *Router) CreatePlaylist(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + p := req.Params(r) + songIds, _ := p.Strings("songId") + playlistId, _ := p.String("playlistId") + name, _ := p.String("name") + if playlistId == "" && name == "" { + return nil, errors.New("required parameter name is missing") + } + id, err := api.create(ctx, playlistId, name, songIds) + if err != nil { + log.Error(r, err) + return nil, err + } + return api.getPlaylist(ctx, id) +} + +func (api *Router) DeletePlaylist(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + err = api.ds.Playlist(r.Context()).Delete(id) + if errors.Is(err, model.ErrNotAuthorized) { + return nil, newError(responses.ErrorAuthorizationFail) + } + if err != nil { + log.Error(r, err) + return nil, err + } + return newResponse(), nil +} + +func (api *Router) UpdatePlaylist(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + playlistId, err := p.String("playlistId") + if err != nil { + return nil, err + } + songsToAdd, _ := p.Strings("songIdToAdd") + songIndexesToRemove, _ := p.Ints("songIndexToRemove") + var plsName *string + if s, err := p.String("name"); err == nil { + plsName = &s + } + comment := p.StringPtr("comment") + public := p.BoolPtr("public") + + log.Debug(r, "Updating playlist", "id", playlistId) + if plsName != nil { + log.Trace(r, fmt.Sprintf("-- New Name: '%s'", *plsName)) + } + log.Trace(r, fmt.Sprintf("-- Adding: '%v'", songsToAdd)) + log.Trace(r, fmt.Sprintf("-- Removing: '%v'", songIndexesToRemove)) + + err = api.playlists.Update(r.Context(), playlistId, plsName, comment, public, songsToAdd, songIndexesToRemove) + if errors.Is(err, model.ErrNotAuthorized) { + return nil, newError(responses.ErrorAuthorizationFail) + } + if err != nil { + log.Error(r, "Error updating playlist", "id", playlistId, err) + return nil, err + } + return newResponse(), nil +} + +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.Changed = time.Now() + } else { + pls.Changed = p.UpdatedAt + } + return pls +} diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go new file mode 100644 index 0000000..c0a007d --- /dev/null +++ b/server/subsonic/playlists_test.go @@ -0,0 +1,88 @@ +package subsonic + +import ( + "context" + + "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 _ core.Playlists = (*fakePlaylists)(nil) + +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) + }) + + 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 { + core.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 new file mode 100644 index 0000000..9f2cd48 --- /dev/null +++ b/server/subsonic/radio.go @@ -0,0 +1,111 @@ +package subsonic + +import ( + "net/http" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) CreateInternetRadio(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + streamUrl, err := p.String("streamUrl") + if err != nil { + return nil, err + } + + name, err := p.String("name") + if err != nil { + return nil, err + } + + homepageUrl, _ := p.String("homepageUrl") + ctx := r.Context() + + radio := &model.Radio{ + StreamUrl: streamUrl, + HomePageUrl: homepageUrl, + Name: name, + } + + err = api.ds.Radio(ctx).Put(radio) + if err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) DeleteInternetRadio(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + + if err != nil { + return nil, err + } + + err = api.ds.Radio(r.Context()).Delete(id) + if err != nil { + return nil, err + } + return newResponse(), nil +} + +func (api *Router) GetInternetRadios(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + radios, err := api.ds.Radio(ctx).GetAll(model.QueryOptions{Sort: "name"}) + if err != nil { + return nil, err + } + + res := make([]responses.Radio, len(radios)) + for i, g := range radios { + res[i] = responses.Radio{ + ID: g.ID, + Name: g.Name, + StreamUrl: g.StreamUrl, + HomepageUrl: g.HomePageUrl, + } + } + + response := newResponse() + response.InternetRadioStations = &responses.InternetRadioStations{ + Radios: res, + } + + return response, nil +} + +func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + + streamUrl, err := p.String("streamUrl") + if err != nil { + return nil, err + } + + name, err := p.String("name") + if err != nil { + return nil, err + } + + homepageUrl, _ := p.String("homepageUrl") + ctx := r.Context() + + radio := &model.Radio{ + ID: id, + StreamUrl: streamUrl, + HomePageUrl: homepageUrl, + Name: name, + } + + err = api.ds.Radio(ctx).Put(radio) + if err != nil { + return nil, err + } + return newResponse(), nil +} 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 new file mode 100644 index 0000000..597737f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .JSON @@ -0,0 +1,15 @@ +{ + "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" + } +} 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 new file mode 100644 index 0000000..be7651c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo with data should match .XML @@ -0,0 +1,10 @@ +<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> 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 new file mode 100644 index 0000000..27f0b26 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..80aff13 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumInfo without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..0e6425f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -0,0 +1,63 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "albumList": { + "album": [ + { + "id": "1", + "isDir": false, + "isVideo": false, + "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" + } + ] + } +} 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 new file mode 100644 index 0000000..07200c0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML @@ -0,0 +1,14 @@ +<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" isVideo="false" 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> + </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 new file mode 100644 index 0000000..9463787 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .JSON @@ -0,0 +1,17 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..000b8c0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with data should match .XML @@ -0,0 +1,5 @@ +<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" 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 new file mode 100644 index 0000000..706eefc --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..d301215 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumList without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..c2a29b2 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -0,0 +1,218 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "album": { + "id": "1", + "name": "album", + "artist": "artist", + "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, + "isVideo": false, + "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" + }, + { + "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, + "isVideo": false, + "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": "" + } + ] + } +} 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 new file mode 100644 index 0000000..1ad3e60 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -0,0 +1,40 @@ +<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" 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" isVideo="false" 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> + </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" isVideo="false"> + <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 new file mode 100644 index 0000000..fbeded4 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -0,0 +1,11 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "album": { + "id": "", + "name": "" + } +} 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 new file mode 100644 index 0000000..159967c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -0,0 +1,3 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> + <album id="" name=""></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 new file mode 100644 index 0000000..758aef0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON @@ -0,0 +1,26 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "album": { + "id": "", + "name": "", + "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 new file mode 100644 index 0000000..159967c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML @@ -0,0 +1,3 @@ +<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="v0.55.0" openSubsonic="true"> + <album id="" name=""></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 new file mode 100644 index 0000000..71d365d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .JSON @@ -0,0 +1,32 @@ +{ + "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 new file mode 100644 index 0000000..799d210 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist with OpenSubsonic data should match .XML @@ -0,0 +1,10 @@ +<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 new file mode 100644 index 0000000..f7d701d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .JSON @@ -0,0 +1,32 @@ +{ + "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 new file mode 100644 index 0000000..630ef91 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist with data and MBID and Sort Name should match .XML @@ -0,0 +1,10 @@ +<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 new file mode 100644 index 0000000..f60df3e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist with data should match .JSON @@ -0,0 +1,26 @@ +{ + "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 new file mode 100644 index 0000000..21bea82 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist with data should match .XML @@ -0,0 +1,7 @@ +<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 new file mode 100644 index 0000000..74bb568 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist without data should match .JSON @@ -0,0 +1,11 @@ +{ + "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 new file mode 100644 index 0000000..7815997 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Artist without data should match .XML @@ -0,0 +1,3 @@ +<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 new file mode 100644 index 0000000..2edaa7e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .JSON @@ -0,0 +1,29 @@ +{ + "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" + } + ] + } +} 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 new file mode 100644 index 0000000..16c6c5f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo with data should match .XML @@ -0,0 +1,13 @@ +<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> 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 new file mode 100644 index 0000000..8e28079 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..16f0ad2 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ArtistInfo without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..7ca38d4 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .JSON @@ -0,0 +1,24 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..66c5782 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks with data should match .XML @@ -0,0 +1,7 @@ +<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" 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 new file mode 100644 index 0000000..267b06e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..c0f1617 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Bookmarks without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..fde4064 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -0,0 +1,151 @@ +{ + "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, + "isVideo": false, + "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" + }, + { + "id": "", + "isDir": false, + "isVideo": false, + "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": "" + } + ], + "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 new file mode 100644 index 0000000..faea8ee --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -0,0 +1,32 @@ +<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" isVideo="false" 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> + </child> + <child id="" isDir="false" isVideo="false"> + <replayGain trackGain="0" albumGain="0" trackPeak="0" albumPeak="0" baseGain="0" fallbackGain="0"></replayGain> + </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 new file mode 100644 index 0000000..66b4983 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match .JSON @@ -0,0 +1,18 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "directory": { + "child": [ + { + "id": "1", + "isDir": false, + "isVideo": false + } + ], + "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 new file mode 100644 index 0000000..d43b9d3 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match .XML @@ -0,0 +1,5 @@ +<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" isVideo="false"></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 new file mode 100644 index 0000000..1af2ec4 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -0,0 +1,37 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "directory": { + "child": [ + { + "id": "1", + "isDir": false, + "isVideo": false, + "bpm": 0, + "comment": "", + "sortName": "", + "mediaType": "", + "musicBrainzId": "", + "isrc": [], + "genres": [], + "replayGain": {}, + "channelCount": 0, + "samplingRate": 0, + "bitDepth": 0, + "moods": [], + "artists": [], + "displayArtist": "", + "albumArtists": [], + "displayAlbumArtist": "", + "contributors": [], + "displayComposer": "", + "explicitStatus": "" + } + ], + "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 new file mode 100644 index 0000000..d43b9d3 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .XML @@ -0,0 +1,5 @@ +<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" isVideo="false"></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 new file mode 100644 index 0000000..daa7b9c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Directory with data should match .JSON @@ -0,0 +1,19 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..2ac4f95 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Directory with data should match .XML @@ -0,0 +1,5 @@ +<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" 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 new file mode 100644 index 0000000..c76abb9 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Directory without data should match .JSON @@ -0,0 +1,11 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..1c1f1d2 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Directory without data should match .XML @@ -0,0 +1,3 @@ +<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> diff --git a/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .JSON b/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .JSON new file mode 100644 index 0000000..d53ba84 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .JSON @@ -0,0 +1,7 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..184228a --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses EmptyResponse should match .XML @@ -0,0 +1 @@ +<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 Genres with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Genres with data should match .JSON new file mode 100644 index 0000000..90d8653 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Genres with data should match .JSON @@ -0,0 +1,26 @@ +{ + "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 + } + ] + } +} 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 new file mode 100644 index 0000000..75497c4 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Genres with data should match .XML @@ -0,0 +1,7 @@ +<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> 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 new file mode 100644 index 0000000..0e473a6 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Genres without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..4f4217d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Genres without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..9704eab --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Indexes with data should match .JSON @@ -0,0 +1,25 @@ +{ + "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" + } +} 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 new file mode 100644 index 0000000..6fc70b4 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Indexes with data should match .XML @@ -0,0 +1,7 @@ +<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> 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 new file mode 100644 index 0000000..e267fcc --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Indexes without data should match .JSON @@ -0,0 +1,11 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..f433b62 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Indexes without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..5762011 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .JSON @@ -0,0 +1,17 @@ +{ + "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" + } + ] + } +} 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 new file mode 100644 index 0000000..24cd687 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations with data should match .XML @@ -0,0 +1,5 @@ +<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> 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 new file mode 100644 index 0000000..30d81f2 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..ba81e42 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses InternetRadioStations without data should match .XML @@ -0,0 +1,3 @@ +<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> diff --git a/server/subsonic/responses/.snapshots/Responses License should match .JSON b/server/subsonic/responses/.snapshots/Responses License should match .JSON new file mode 100644 index 0000000..00f3ab7 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses License should match .JSON @@ -0,0 +1,10 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..f892e6f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses License should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..e2c2b4d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .JSON @@ -0,0 +1,12 @@ +{ + "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" + } +} 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 new file mode 100644 index 0000000..52c0ff3 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Lyrics with data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..d6d4029 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .JSON @@ -0,0 +1,10 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..d7fcb28 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Lyrics without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..e027d62 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .JSON @@ -0,0 +1,43 @@ +{ + "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 new file mode 100644 index 0000000..0f1c6c5 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses LyricsList with data should match .XML @@ -0,0 +1,12 @@ +<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 new file mode 100644 index 0000000..c552df1 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .JSON @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..3cc86c3 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses LyricsList without data should match .XML @@ -0,0 +1,3 @@ +<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 new file mode 100644 index 0000000..84555b7 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .JSON @@ -0,0 +1,19 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..a9517ea --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders with data should match .XML @@ -0,0 +1,6 @@ +<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> 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 new file mode 100644 index 0000000..5c0fb8b --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..5237139 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses MusicFolders without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..d3972e7 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .JSON @@ -0,0 +1,16 @@ +{ + "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 new file mode 100644 index 0000000..adcb008 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions with data should match .XML @@ -0,0 +1,6 @@ +<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 new file mode 100644 index 0000000..b81ecd0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .JSON @@ -0,0 +1,8 @@ +{ + "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 new file mode 100644 index 0000000..184228a --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses OpenSubsonicExtensions without data should match .XML @@ -0,0 +1 @@ +<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 new file mode 100644 index 0000000..eb77169 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .JSON @@ -0,0 +1,22 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..1156af0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue with data should match .XML @@ -0,0 +1,5 @@ +<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" 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 new file mode 100644 index 0000000..70b10c0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON @@ -0,0 +1,12 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "playQueue": { + "username": "", + "changed": "0001-01-01T00:00:00Z", + "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 new file mode 100644 index 0000000..597781c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..efc032c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON @@ -0,0 +1,22 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "playQueueByIndex": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "title", + "isVideo": false + } + ], + "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 new file mode 100644 index 0000000..1d31b33 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML @@ -0,0 +1,5 @@ +<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" isVideo="false"></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 new file mode 100644 index 0000000..ad49a35 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..d99681f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML @@ -0,0 +1,3 @@ +<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 new file mode 100644 index 0000000..b6e996d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .JSON @@ -0,0 +1,32 @@ +{ + "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": "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 new file mode 100644 index 0000000..100301a --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Playlists with data should match .XML @@ -0,0 +1,6 @@ +<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="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 new file mode 100644 index 0000000..c4510a7 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Playlists without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..acdb673 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Playlists without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..af26f09 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .JSON @@ -0,0 +1,13 @@ +{ + "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" + } +} 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 new file mode 100644 index 0000000..6ce0dac --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus with data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..fed45c5 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .JSON @@ -0,0 +1,12 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..8e622d8 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses ScanStatus without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..0c08be3 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Shares with data should match .JSON @@ -0,0 +1,41 @@ +{ + "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, + "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": "2016-03-02T20:30:00Z", + "expires": "2016-03-02T20:30:00Z", + "lastVisited": "2016-03-02T20:30: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 new file mode 100644 index 0000000..36cfc25 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Shares with data should match .XML @@ -0,0 +1,8 @@ +<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" 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 new file mode 100644 index 0000000..2856ac7 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .JSON @@ -0,0 +1,18 @@ +{ + "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 new file mode 100644 index 0000000..12e8f6b --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Shares with only required fields should match .XML @@ -0,0 +1,5 @@ +<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 new file mode 100644 index 0000000..d05e140 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Shares without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..9217c78 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Shares without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..7df08de --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .JSON @@ -0,0 +1,17 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..b05443a --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs with data should match .XML @@ -0,0 +1,5 @@ +<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" 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 new file mode 100644 index 0000000..2436e38 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..c3e020a --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..73eda01 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .JSON @@ -0,0 +1,17 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..0402f03 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 with data should match .XML @@ -0,0 +1,5 @@ +<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" 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 new file mode 100644 index 0000000..1d86c94 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..aa30124 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SimilarSongs2 without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..575c9b7 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .JSON @@ -0,0 +1,17 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..35a77cb --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses TopSongs with data should match .XML @@ -0,0 +1,5 @@ +<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" 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 new file mode 100644 index 0000000..68ef265 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.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 new file mode 100644 index 0000000..74f5d1c --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses TopSongs without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..94ca289 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses User with data should match .JSON @@ -0,0 +1,27 @@ +{ + "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 + ] + } +} 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 new file mode 100644 index 0000000..18fae22 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses User with data should match .XML @@ -0,0 +1,5 @@ +<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> 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 new file mode 100644 index 0000000..fb78819 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses User without data should match .JSON @@ -0,0 +1,23 @@ +{ + "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 + } +} 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 new file mode 100644 index 0000000..16ebce7 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses User without data should match .XML @@ -0,0 +1,3 @@ +<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> 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 new file mode 100644 index 0000000..4688feb --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Users with data should match .JSON @@ -0,0 +1,31 @@ +{ + "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 + ] + } + ] + } +} 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 new file mode 100644 index 0000000..f40d323 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Users with data should match .XML @@ -0,0 +1,7 @@ +<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> 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 new file mode 100644 index 0000000..96b6973 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Users without data should match .JSON @@ -0,0 +1,27 @@ +{ + "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 + } + ] + } +} 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 new file mode 100644 index 0000000..3033ad9 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses Users without data should match .XML @@ -0,0 +1,5 @@ +<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> diff --git a/server/subsonic/responses/errors.go b/server/subsonic/responses/errors.go new file mode 100644 index 0000000..42e5427 --- /dev/null +++ b/server/subsonic/responses/errors.go @@ -0,0 +1,30 @@ +package responses + +const ( + ErrorGeneric int32 = 0 + ErrorMissingParameter int32 = 10 + ErrorClientTooOld int32 = 20 + ErrorServerTooOld int32 = 30 + ErrorAuthenticationFail int32 = 40 + ErrorAuthorizationFail int32 = 50 + ErrorTrialExpired int32 = 60 + ErrorDataNotFound int32 = 70 +) + +var errors = map[int32]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 { + if v, found := errors[code]; found { + return v + } + return errors[ErrorGeneric] +} diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go new file mode 100644 index 0000000..0724d2f --- /dev/null +++ b/server/subsonic/responses/responses.go @@ -0,0 +1,618 @@ +package responses + +import ( + "encoding/json" + "encoding/xml" + "time" +) + +type Subsonic struct { + XMLName xml.Name `xml:"http://subsonic.org/restapi subsonic-response" json:"-"` + Status string `xml:"status,attr" json:"status"` + Version string `xml:"version,attr" json:"version"` + Type string `xml:"type,attr" json:"type"` + ServerVersion string `xml:"serverVersion,attr" json:"serverVersion"` + OpenSubsonic bool `xml:"openSubsonic,attr,omitempty" json:"openSubsonic,omitempty"` + Error *Error `xml:"error,omitempty" json:"error,omitempty"` + License *License `xml:"license,omitempty" json:"license,omitempty"` + MusicFolders *MusicFolders `xml:"musicFolders,omitempty" json:"musicFolders,omitempty"` + Indexes *Indexes `xml:"indexes,omitempty" json:"indexes,omitempty"` + Directory *Directory `xml:"directory,omitempty" json:"directory,omitempty"` + 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"` + 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"` + NowPlaying *NowPlaying `xml:"nowPlaying,omitempty" json:"nowPlaying,omitempty"` + Song *Child `xml:"song,omitempty" json:"song,omitempty"` + RandomSongs *Songs `xml:"randomSongs,omitempty" json:"randomSongs,omitempty"` + SongsByGenre *Songs `xml:"songsByGenre,omitempty" json:"songsByGenre,omitempty"` + Genres *Genres `xml:"genres,omitempty" json:"genres,omitempty"` + + // ID3 + Artist *Artists `xml:"artists,omitempty" json:"artists,omitempty"` + ArtistWithAlbumsID3 *ArtistWithAlbumsID3 `xml:"artist,omitempty" json:"artist,omitempty"` + AlbumWithSongsID3 *AlbumWithSongsID3 `xml:"album,omitempty" json:"album,omitempty"` + + AlbumInfo *AlbumInfo `xml:"albumInfo,omitempty" json:"albumInfo,omitempty"` + ArtistInfo *ArtistInfo `xml:"artistInfo,omitempty" json:"artistInfo,omitempty"` + ArtistInfo2 *ArtistInfo2 `xml:"artistInfo2,omitempty" json:"artistInfo2,omitempty"` + SimilarSongs *SimilarSongs `xml:"similarSongs,omitempty" json:"similarSongs,omitempty"` + SimilarSongs2 *SimilarSongs2 `xml:"similarSongs2,omitempty" json:"similarSongs2,omitempty"` + TopSongs *TopSongs `xml:"topSongs,omitempty" json:"topSongs,omitempty"` + + PlayQueue *PlayQueue `xml:"playQueue,omitempty" json:"playQueue,omitempty"` + Shares *Shares `xml:"shares,omitempty" json:"shares,omitempty"` + Bookmarks *Bookmarks `xml:"bookmarks,omitempty" json:"bookmarks,omitempty"` + 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 + OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"` + LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` + PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` +} + +const ( + StatusOK = "ok" + StatusFailed = "failed" +) + +type JsonWrapper struct { + Subsonic Subsonic `json:"subsonic-response"` +} + +type Error struct { + Code int32 `xml:"code,attr" json:"code"` + Message string `xml:"message,attr" json:"message"` +} + +type License struct { + Valid bool `xml:"valid,attr" json:"valid"` +} + +type MusicFolder struct { + Id int32 `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` +} + +type MusicFolders struct { + Folders []MusicFolder `xml:"musicFolder" json:"musicFolder,omitempty"` +} + +type Artist struct { + Id string `xml:"id,attr" json:"id"` + Name string `xml:"name,attr" json:"name"` + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + UserRating int32 `xml:"userRating,attr,omitempty" json:"userRating,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 { + Name string `xml:"name,attr" json:"name"` + Artists []Artist `xml:"artist" json:"artist"` +} + +type Indexes struct { + Index []Index `xml:"index" json:"index,omitempty"` + LastModified int64 `xml:"lastModified,attr" json:"lastModified"` + 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,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"` + Track int32 `xml:"track,attr,omitempty" json:"track,omitempty"` + Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` + Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` + CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` + Size int64 `xml:"size,attr,omitempty" json:"size,omitempty"` + ContentType string `xml:"contentType,attr,omitempty" json:"contentType,omitempty"` + Suffix string `xml:"suffix,attr,omitempty" json:"suffix,omitempty"` + Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` + TranscodedContentType string `xml:"transcodedContentType,attr,omitempty" json:"transcodedContentType,omitempty"` + TranscodedSuffix string `xml:"transcodedSuffix,attr,omitempty" json:"transcodedSuffix,omitempty"` + Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` + 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"` + 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"` + SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` + IsVideo bool `xml:"isVideo,attr" json:"isVideo"` + BookmarkPosition int64 `xml:"bookmarkPosition,attr,omitempty" json:"bookmarkPosition,omitempty"` + /* + <xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.6.0 --> + */ + *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"` +} + +type Songs struct { + Songs []Child `xml:"song" json:"song,omitempty"` +} + +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"` + + // ID3 + 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"` + AlbumCount int32 `xml:"albumCount,attr,omitempty" json:"albumCount,omitempty"` + Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` + 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"` + + /* + <xs:attribute name="averageRating" type="sub:AverageRating" use="optional"/> <!-- Added in 1.13.0 --> + */ +} + +// 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"` +} + +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"` + 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"` +} + +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,omitempty" json:"songCount,omitempty"` + Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` + PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` + Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` + 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"` + 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"` +} + +type ArtistWithAlbumsID3 struct { + ArtistID3 + Album []AlbumID3 `xml:"album" json:"album,omitempty"` +} + +type AlbumWithSongsID3 struct { + AlbumID3 + Song []Child `xml:"song" json:"song,omitempty"` +} + +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"` + 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--> + </xs:sequence> + */ +} + +type Playlists struct { + Playlist []Playlist `xml:"playlist" json:"playlist,omitempty"` +} + +type PlaylistWithSongs struct { + Playlist + Entry []Child `xml:"entry" json:"entry,omitempty"` +} + +type SearchResult2 struct { + Artist []Artist `xml:"artist" json:"artist,omitempty"` + Album []Child `xml:"album" json:"album,omitempty"` + Song []Child `xml:"song" json:"song,omitempty"` +} + +type SearchResult3 struct { + Artist []ArtistID3 `xml:"artist" json:"artist,omitempty"` + Album []AlbumID3 `xml:"album" json:"album,omitempty"` + Song []Child `xml:"song" json:"song,omitempty"` +} + +type Starred struct { + Artist []Artist `xml:"artist" json:"artist,omitempty"` + Album []Child `xml:"album" json:"album,omitempty"` + 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"` +} + +type NowPlaying struct { + Entry []NowPlayingEntry `xml:"entry" json:"entry,omitempty"` +} + +type User struct { + Username string `xml:"username,attr" json:"username"` + Email string `xml:"email,attr,omitempty" json:"email,omitempty"` + ScrobblingEnabled bool `xml:"scrobblingEnabled,attr" json:"scrobblingEnabled"` + MaxBitRate int32 `xml:"maxBitRate,attr,omitempty" json:"maxBitRate,omitempty"` + AdminRole bool `xml:"adminRole,attr" json:"adminRole"` + SettingsRole bool `xml:"settingsRole,attr" json:"settingsRole"` + DownloadRole bool `xml:"downloadRole,attr" json:"downloadRole"` + UploadRole bool `xml:"uploadRole,attr" json:"uploadRole"` + PlaylistRole bool `xml:"playlistRole,attr" json:"playlistRole"` + CoverArtRole bool `xml:"coverArtRole,attr" json:"coverArtRole"` + CommentRole bool `xml:"commentRole,attr" json:"commentRole"` + PodcastRole bool `xml:"podcastRole,attr" json:"podcastRole"` + StreamRole bool `xml:"streamRole,attr" json:"streamRole"` + JukeboxRole bool `xml:"jukeboxRole,attr" json:"jukeboxRole"` + ShareRole bool `xml:"shareRole,attr" json:"shareRole"` + VideoConversionRole bool `xml:"videoConversionRole,attr" json:"videoConversionRole"` + Folder []int32 `xml:"folder,omitempty" json:"folder,omitempty"` +} + +type Users struct { + User []User `xml:"user" json:"user"` +} + +type Genre struct { + Name string `xml:",chardata" json:"value,omitempty"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` + AlbumCount int32 `xml:"albumCount,attr" json:"albumCount"` +} + +type Genres struct { + Genre []Genre `xml:"genre,omitempty" json:"genre,omitempty"` +} + +type AlbumInfo struct { + Notes string `xml:"notes,omitempty" json:"notes,omitempty"` + MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"` + LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"` + SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"` + MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"` + LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"` +} + +type ArtistInfoBase struct { + Biography string `xml:"biography,omitempty" json:"biography,omitempty"` + MusicBrainzID string `xml:"musicBrainzId,omitempty" json:"musicBrainzId,omitempty"` + LastFmUrl string `xml:"lastFmUrl,omitempty" json:"lastFmUrl,omitempty"` + SmallImageUrl string `xml:"smallImageUrl,omitempty" json:"smallImageUrl,omitempty"` + MediumImageUrl string `xml:"mediumImageUrl,omitempty" json:"mediumImageUrl,omitempty"` + LargeImageUrl string `xml:"largeImageUrl,omitempty" json:"largeImageUrl,omitempty"` +} + +type ArtistInfo struct { + ArtistInfoBase + SimilarArtist []Artist `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"` +} + +type ArtistInfo2 struct { + ArtistInfoBase + SimilarArtist []ArtistID3 `xml:"similarArtist,omitempty" json:"similarArtist,omitempty"` +} + +type SimilarSongs struct { + Song []Child `xml:"song,omitempty" json:"song,omitempty"` +} + +type SimilarSongs2 struct { + Song []Child `xml:"song,omitempty" json:"song,omitempty"` +} + +type TopSongs struct { + Song []Child `xml:"song,omitempty" json:"song,omitempty"` +} + +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"` +} + +type Bookmark struct { + 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"` + Created time.Time `xml:"created,attr" json:"created"` + Changed time.Time `xml:"changed,attr" json:"changed"` +} + +type Bookmarks struct { + Bookmark []Bookmark `xml:"bookmark,omitempty" json:"bookmark,omitempty"` +} + +type Share struct { + Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` + ID string `xml:"id,attr" json:"id"` + Url string `xml:"url,attr" json:"url"` + Description string `xml:"description,omitempty,attr" json:"description,omitempty"` + 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"` + VisitCount int32 `xml:"visitCount,attr" json:"visitCount"` +} + +type Shares struct { + Share []Share `xml:"share,omitempty" json:"share,omitempty"` +} + +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"` +} + +type Lyrics struct { + Artist string `xml:"artist,omitempty,attr" json:"artist,omitempty"` + Title string `xml:"title,omitempty,attr" json:"title,omitempty"` + Value string `xml:",chardata" json:"value"` +} + +type InternetRadioStations struct { + Radios []Radio `xml:"internetRadioStation" json:"internetRadioStation,omitempty"` +} + +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"` +} + +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 StructuredLyric struct { + DisplayArtist string `xml:"displayArtist,attr,omitempty" json:"displayArtist,omitempty"` + DisplayTitle string `xml:"displayTitle,attr,omitempty" json:"displayTitle,omitempty"` + Lang string `xml:"lang,attr" json:"lang"` + Line []Line `xml:"line" json:"line"` + 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 ItemGenre struct { + Name string `xml:"name,attr" json:"name"` +} + +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"` +} + +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) +} diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go new file mode 100644 index 0000000..8728b94 --- /dev/null +++ b/server/subsonic/responses/responses_suite_test.go @@ -0,0 +1,42 @@ +package responses + +import ( + "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" +) + +func TestSubsonicApiResponses(t *testing.T) { + log.SetLevel(log.LevelError) + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Subsonic API Responses Suite") +} + +func MatchSnapshot() types.GomegaMatcher { + c := cupaloy.New(cupaloy.FailOnUpdate(false)) + return &snapshotMatcher{c} +} + +type snapshotMatcher struct { + c *cupaloy.Config +} + +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(_ interface{}) (message string) { + return "Expected to match saved snapshot\n" +} + +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 new file mode 100644 index 0000000..2ee8e08 --- /dev/null +++ b/server/subsonic/responses/responses_test.go @@ -0,0 +1,1111 @@ +//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 ( + "encoding/json" + "encoding/xml" + "time" + + "github.com/navidrome/navidrome/consts" + . "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Responses", func() { + var response *Subsonic + BeforeEach(func() { + response = &Subsonic{ + Status: StatusOK, + Version: "1.16.1", + Type: consts.AppName, + ServerVersion: "v0.55.0", + OpenSubsonic: true, + } + }) + + Describe("EmptyResponse", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("License", func() { + BeforeEach(func() { + response.License = &License{Valid: true} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("MusicFolders", func() { + BeforeEach(func() { + response.MusicFolders = &MusicFolders{} + }) + + 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() { + folders := make([]MusicFolder, 2) + folders[0] = MusicFolder{Id: 111, Name: "aaa"} + folders[1] = MusicFolder{Id: 222, Name: "bbb"} + response.MusicFolders.Folders = folders + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("Indexes", func() { + BeforeEach(func() { + response.Indexes = &Indexes{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([]Artist, 1) + t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) + artists[0] = Artist{ + Id: "111", + Name: "aaa", + Starred: &t, + UserRating: 3, + ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", + } + index := make([]Index, 1) + index[0] = Index{Name: "A", Artists: artists} + response.Indexes.Index = index + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(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) + t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) + artists[0] = ArtistID3{ + Id: "111", + Name: "aaa", + Starred: &t, + 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) + t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) + artists[0] = ArtistID3{ + Id: "111", + Name: "aaa", + Starred: &t, + 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()) + }) + }) + }) + + 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) + 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: &t, + } + 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"}, + ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(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", + } + child[1].OpenSubsonicChild = &OpenSubsonicChild{ + ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + } + response.Directory.Child = child + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(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", 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"}, + ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(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: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(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()) + }) + }) + }) + + Describe("Directory", func() { + BeforeEach(func() { + response.Directory = &Directory{Id: "1", Name: "N"} + }) + + 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() { + child := make([]Child, 1) + child[0] = Child{Id: "1", Title: "title", IsDir: false} + response.Directory.Child = child + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("AlbumList", func() { + BeforeEach(func() { + response.AlbumList = &AlbumList{} + }) + + 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() { + child := make([]Child, 1) + child[0] = Child{Id: "1", Title: "title", IsDir: false} + 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()) + }) + }) + + 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"}, + 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()) + }) + }) + }) + + Describe("User", func() { + BeforeEach(func() { + response.User = &User{Username: "deluan"} + }) + + 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.User.Email = "navidrome@deluan.com" + response.User.Folder = []int32{1} + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("Users", func() { + BeforeEach(func() { + u := User{Username: "deluan"} + response.Users = &Users{User: []User{u}} + }) + + 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() { + u := User{Username: "deluan"} + u.Email = "navidrome@deluan.com" + u.AdminRole = true + u.Folder = []int32{1} + response.Users = &Users{User: []User{u}} + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("Playlists", func() { + BeforeEach(func() { + response.Playlists = &Playlists{} + }) + + 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() { + timestamp, _ := time.Parse(time.RFC3339, "2020-04-11T16:43:00Z04:00") + BeforeEach(func() { + pls := make([]Playlist, 2) + pls[0] = Playlist{ + Id: "111", + Name: "aaa", + Comment: "comment", + SongCount: 2, + Duration: 120, + Public: true, + Owner: "admin", + CoverArt: "pl-123123123123", + Created: timestamp, + Changed: timestamp, + } + pls[1] = Playlist{Id: "222", Name: "bbb"} + response.Playlists.Playlist = pls + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("Genres", func() { + BeforeEach(func() { + response.Genres = &Genres{} + }) + + 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() { + genres := make([]Genre, 3) + genres[0] = Genre{SongCount: 1000, AlbumCount: 100, Name: "Rock"} + genres[1] = Genre{SongCount: 500, AlbumCount: 50, Name: "Reggae"} + genres[2] = Genre{SongCount: 0, AlbumCount: 0, Name: "Pop"} + response.Genres.Genre = genres + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("AlbumInfo", func() { + BeforeEach(func() { + response.AlbumInfo = &AlbumInfo{} + }) + + 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.AlbumInfo.SmallImageUrl = "https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png" + response.AlbumInfo.MediumImageUrl = "https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png" + response.AlbumInfo.LargeImageUrl = "https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png" + response.AlbumInfo.LastFmUrl = "https://www.last.fm/music/Cher/Believe" + response.AlbumInfo.MusicBrainzID = "03c91c40-49a6-44a7-90e7-a700edf97a62" + response.AlbumInfo.Notes = "Believe is the twenty-third studio album by American singer-actress Cher..." + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("ArtistInfo", func() { + BeforeEach(func() { + response.ArtistInfo = &ArtistInfo{} + }) + + 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.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.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" + response.ArtistInfo.LargeImageUrl = "https://userserve-ak.last.fm/serve/_/27904353/Black+Sabbath+sabbath+1970.jpg" + response.ArtistInfo.SimilarArtist = []Artist{ + {Id: "22", Name: "Accept"}, + {Id: "101", Name: "Bruce Dickinson"}, + {Id: "26", Name: "Aerosmith"}, + } + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("TopSongs", func() { + BeforeEach(func() { + response.TopSongs = &TopSongs{} + }) + + 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() { + child := make([]Child, 1) + child[0] = Child{Id: "1", Title: "title", IsDir: false} + response.TopSongs.Song = child + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("SimilarSongs", func() { + BeforeEach(func() { + response.SimilarSongs = &SimilarSongs{} + }) + + 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() { + child := make([]Child, 1) + child[0] = Child{Id: "1", Title: "title", IsDir: false} + response.SimilarSongs.Song = child + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("SimilarSongs2", func() { + BeforeEach(func() { + response.SimilarSongs2 = &SimilarSongs2{} + }) + + 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() { + child := make([]Child, 1) + child[0] = Child{Id: "1", Title: "title", IsDir: false} + response.SimilarSongs2.Song = child + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("PlayQueue", func() { + BeforeEach(func() { + response.PlayQueue = &PlayQueue{} + }) + + 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.PlayQueue.Username = "user1" + response.PlayQueue.Current = "111" + response.PlayQueue.Position = 243 + 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()) + }) + 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 = gg.P(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()) + }) + }) + }) + + Describe("Shares", func() { + BeforeEach(func() { + response.Shares = &Shares{} + }) + + 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 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()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) + share := Share{ + ID: "ABC123", + Url: "http://localhost/p/ABC123", + Description: "Check it out!", + Username: "deluan", + Created: t, + Expires: &t, + LastVisited: &t, + VisitCount: 2, + } + share.Entry = make([]Child, 2) + share.Entry[0] = Child{Id: "1", Title: "title", Album: "album", Artist: "artist", Duration: 120} + share.Entry[1] = Child{Id: "2", Title: "title 2", Album: "album", Artist: "artist", Duration: 300} + response.Shares.Share = []Share{share} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("Bookmarks", func() { + BeforeEach(func() { + response.Bookmarks = &Bookmarks{} + }) + + 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() { + bmk := Bookmark{ + Position: 123, + Username: "user2", + Comment: "a comment", + Created: time.Time{}, + Changed: time.Time{}, + } + bmk.Entry = Child{Id: "1", Title: "title", IsDir: false} + response.Bookmarks.Bookmark = []Bookmark{bmk} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("ScanStatus", func() { + BeforeEach(func() { + response.ScanStatus = &ScanStatus{} + }) + + 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() { + timeFmt := "2006-01-02 15:04:00" + t, _ := time.Parse(timeFmt, timeFmt) + response.ScanStatus = &ScanStatus{ + Scanning: true, + FolderCount: 123, + Count: 456, + LastScan: &t, + } + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + + Describe("Lyrics", func() { + BeforeEach(func() { + response.Lyrics = &Lyrics{} + }) + + 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.Lyrics.Artist = "Rick Astley" + response.Lyrics.Title = "Never Gonna Give You Up" + response.Lyrics.Value = `Never gonna give you up + Never gonna let you down + Never gonna run around and desert you + Never gonna say goodbye` + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(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("InternetRadioStations", func() { + BeforeEach(func() { + response.InternetRadioStations = &InternetRadioStations{} + }) + + 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() { + radio := make([]Radio, 1) + radio[0] = Radio{ + ID: "12345678", + StreamUrl: "https://example.com/stream", + Name: "Example Stream", + HomepageUrl: "https://example.com", + } + response.InternetRadioStations.Radios = radio + }) + + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(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()) + }) + }) + }) + +}) diff --git a/server/subsonic/searching.go b/server/subsonic/searching.go new file mode 100644 index 0000000..ba10713 --- /dev/null +++ b/server/subsonic/searching.go @@ -0,0 +1,156 @@ +package subsonic + +import ( + "context" + "fmt" + "net/http" + "reflect" + "strings" + "time" + + . "github.com/Masterminds/squirrel" + "github.com/deluan/sanitize" + "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" +) + +type searchParams struct { + query string + artistCount int + artistOffset int + albumCount int + albumOffset int + songCount int + songOffset int +} + +func (api *Router) getSearchParams(r *http.Request) (*searchParams, error) { + p := req.Params(r) + 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) + return sp, nil +} + +type searchFunc[T any] func(q string, offset int, size int, options ...model.QueryOptions) (T, error) + +func callSearch[T any](ctx context.Context, s searchFunc[T], q string, offset, size int, result *T, options ...model.QueryOptions) func() error { + return func() error { + if size == 0 { + return nil + } + typ := strings.TrimPrefix(reflect.TypeOf(*result).String(), "model.") + var err error + start := time.Now() + *result, err = s(q, offset, size, options...) + if err != nil { + log.Error(ctx, "Error searching "+typ, "query", q, "elapsed", time.Since(start), err) + } else { + log.Trace(ctx, "Search for "+typ+" completed", "query", q, "elapsed", time.Since(start)) + } + return nil + } +} + +func (api *Router) searchAll(ctx context.Context, sp *searchParams, musicFolderIds []int) (mediaFiles model.MediaFiles, albums model.Albums, artists model.Artists) { + start := time.Now() + q := sanitize.Accents(strings.ToLower(strings.TrimSuffix(sp.query, "*"))) + + // Create query options for library filtering + var options []model.QueryOptions + var artistOptions []model.QueryOptions + if len(musicFolderIds) > 0 { + // For MediaFiles and Albums, use direct library_id filter + options = append(options, model.QueryOptions{ + Filters: Eq{"library_id": musicFolderIds}, + }) + // For Artists, use the repository's built-in library filtering mechanism + // which properly handles the library_artist table joins + // TODO Revisit library filtering in sql_base_repository.go + artistOptions = append(artistOptions, model.QueryOptions{ + Filters: Eq{"library_artist.library_id": musicFolderIds}, + }) + } + + // Run searches in parallel + g, ctx := errgroup.WithContext(ctx) + g.Go(callSearch(ctx, api.ds.MediaFile(ctx).Search, q, sp.songOffset, sp.songCount, &mediaFiles, options...)) + g.Go(callSearch(ctx, api.ds.Album(ctx).Search, q, sp.albumOffset, sp.albumCount, &albums, options...)) + g.Go(callSearch(ctx, api.ds.Artist(ctx).Search, q, sp.artistOffset, sp.artistCount, &artists, artistOptions...)) + err := g.Wait() + if 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) + } + return mediaFiles, albums, artists +} + +func (api *Router) Search2(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + sp, err := api.getSearchParams(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) + + 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: artist.CoverArtID().String(), + ArtistImageUrl: public.ImageURL(r, artist.CoverArtID(), 600), + } + if artist.Starred { + a.Starred = artist.StarredAt + } + return a + }) + searchResult2.Album = slice.MapWithArg(als, ctx, childFromAlbum) + searchResult2.Song = slice.MapWithArg(mfs, ctx, childFromMediaFile) + response.SearchResult2 = searchResult2 + return response, nil +} + +func (api *Router) Search3(r *http.Request) (*responses.Subsonic, error) { + ctx := r.Context() + sp, err := api.getSearchParams(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) + + 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) + response.SearchResult3 = searchResult3 + return response, nil +} diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go new file mode 100644 index 0000000..dfe3a45 --- /dev/null +++ b/server/subsonic/searching_test.go @@ -0,0 +1,208 @@ +package subsonic + +import ( + "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) + + // Get references to the mock repositories so we can inspect their Options + mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) + mockArtistRepo = ds.Artist(nil).(*tests.MockArtistRepo) + mockMediaFileRepo = ds.MediaFile(nil).(*tests.MockMediaFileRepo) + }) + + Context("musicFolderId parameter", func() { + assertQueryOptions := func(filter squirrel.Sqlizer, expectedQuery string, expectedArgs ...interface{}) { + GinkgoHelper() + query, args, err := filter.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(query).To(ContainSubstring(expectedQuery)) + Expect(args).To(ContainElements(expectedArgs...)) + } + + Describe("Search2", func() { + It("should accept musicFolderId parameter", 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"}}, + }) + r = r.WithContext(ctx) + + resp, err := router.Search2(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult2).ToNot(BeNil()) + + // Verify that library filter was applied to all repositories + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + }) + + It("should return results from all accessible libraries when musicFolderId is not provided", func() { + r := newGetRequest("query=test") + 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()) + + // Verify that library filter was applied to all repositories with all accessible libraries + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockMediaFileRepo.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("should accept musicFolderId parameter", 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"}}, + }) + r = r.WithContext(ctx) + + resp, err := router.Search3(r) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp).ToNot(BeNil()) + Expect(resp.SearchResult3).ToNot(BeNil()) + + // Verify that library filter was applied to all repositories + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?)", 1) + assertQueryOptions(mockMediaFileRepo.Options.Filters, "library_id IN (?)", 1) + }) + + It("should return results from all accessible libraries when musicFolderId is not provided", func() { + r := newGetRequest("query=test") + 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()) + + // Verify that library filter was applied to all repositories with all accessible libraries + assertQueryOptions(mockAlbumRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockArtistRepo.Options.Filters, "library_id IN (?,?,?)", 1, 2, 3) + assertQueryOptions(mockMediaFileRepo.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()) + }) + }) + }) +}) diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go new file mode 100644 index 0000000..9cc8d70 --- /dev/null +++ b/server/subsonic/sharing.go @@ -0,0 +1,124 @@ +package subsonic + +import ( + "net/http" + "strings" + "time" + + "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" +) + +func (api *Router) GetShares(r *http.Request) (*responses.Subsonic, error) { + repo := api.share.NewRepository(r.Context()).(model.ShareRepository) + shares, err := repo.GetAll(model.QueryOptions{Sort: "created_at desc"}) + if err != nil { + return nil, err + } + + response := newResponse() + response.Shares = &responses.Shares{} + for _, share := range shares { + response.Shares.Share = append(response.Shares.Share, api.buildShare(r, share)) + } + return response, nil +} + +func (api *Router) buildShare(r *http.Request, share model.Share) responses.Share { + resp := responses.Share{ + ID: share.ID, + Url: public.ShareURL(r, share.ID), + Description: share.Description, + Username: share.Username, + Created: share.CreatedAt, + Expires: share.ExpiresAt, + LastVisited: share.LastVisitedAt, + VisitCount: int32(share.VisitCount), + } + if resp.Description == "" { + resp.Description = share.Contents + } + if len(share.Albums) > 0 { + resp.Entry = slice.MapWithArg(share.Albums, r.Context(), childFromAlbum) + } else { + resp.Entry = slice.MapWithArg(share.Tracks, r.Context(), childFromMediaFile) + } + return resp +} + +func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + ids, err := p.Strings("id") + if err != nil { + return nil, err + } + + description, _ := p.String("description") + expires := p.TimeOr("expires", time.Time{}) + + repo := api.share.NewRepository(r.Context()) + share := &model.Share{ + Description: description, + ExpiresAt: &expires, + ResourceIDs: strings.Join(ids, ","), + } + + id, err := repo.(rest.Persistable).Save(share) + if err != nil { + return nil, err + } + + share, err = repo.(model.ShareRepository).Get(id) + if err != nil { + return nil, err + } + + response := newResponse() + response.Shares = &responses.Shares{Share: []responses.Share{api.buildShare(r, *share)}} + return response, nil +} + +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 + } + + description, _ := p.String("description") + expires := p.TimeOr("expires", time.Time{}) + + repo := api.share.NewRepository(r.Context()) + share := &model.Share{ + ID: id, + Description: description, + ExpiresAt: &expires, + } + + err = repo.(rest.Persistable).Update(id, share) + if err != nil { + return nil, err + } + + return newResponse(), nil +} + +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 + } + + repo := api.share.NewRepository(r.Context()) + err = repo.(rest.Persistable).Delete(id) + if err != nil { + return nil, err + } + + return newResponse(), nil +} diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go new file mode 100644 index 0000000..d0cbe20 --- /dev/null +++ b/server/subsonic/stream.go @@ -0,0 +1,163 @@ +package subsonic + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/navidrome/navidrome/conf" + "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" +) + +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 := req.Params(r).BoolOr("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.IsGreaterOrEqualTo(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") + if err != nil { + return nil, err + } + maxBitRate := p.IntOr("maxBitRate", 0) + format, _ := p.String("format") + timeOffset := p.IntOr("timeOffset", 0) + + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, timeOffset) + 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) { + log.Error("Error closing stream", "id", id, "file", stream.Name(), err) + } + }() + + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) + + 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") + if err != nil { + return nil, err + } + + if !conf.Server.EnableDownloads { + log.Warn(ctx, "Downloads are disabled", "user", username, "id", id) + return nil, newError(responses.ErrorAuthorizationFail, "downloads are disabled") + } + + entity, err := model.GetEntityByID(ctx, api.ds, id) + if err != nil { + return nil, err + } + + maxBitRate := p.IntOr("bitrate", 0) + format, _ := p.String("format") + + if format == "" { + if conf.Server.AutoTranscodeDownload { + // if we are not provided a format, see if we have requested transcoding for this client + // This must be enabled via a config option. For the UI, we are always given an option. + // This will impact other clients which do not use the UI + transcoding, ok := request.TranscodingFrom(ctx) + + if !ok { + format = "raw" + } else { + format = transcoding.TargetFormat + maxBitRate = transcoding.DefaultBitRate + } + } else { + format = "raw" + } + } + + setHeaders := func(name string) { + 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: + stream, err := api.streamer.NewStream(ctx, id, format, maxBitRate, 0) + 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) { + log.Error("Error closing stream", "id", id, "file", stream.Name(), err) + } + }() + + disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) + w.Header().Set("Content-Disposition", disposition) + + api.serveStream(ctx, w, r, stream, id) + return nil, nil + case *model.Album: + setHeaders(v.Name) + err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) + case *model.Artist: + setHeaders(v.Name) + err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) + case *model.Playlist: + setHeaders(v.Name) + err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) + default: + err = model.ErrNotFound + } + + return nil, err +} diff --git a/server/subsonic/system.go b/server/subsonic/system.go new file mode 100644 index 0000000..e140999 --- /dev/null +++ b/server/subsonic/system.go @@ -0,0 +1,17 @@ +package subsonic + +import ( + "net/http" + + "github.com/navidrome/navidrome/server/subsonic/responses" +) + +func (api *Router) Ping(_ *http.Request) (*responses.Subsonic, error) { + return newResponse(), nil +} + +func (api *Router) GetLicense(_ *http.Request) (*responses.Subsonic, error) { + response := newResponse() + response.License = &responses.License{Valid: true} + return response, nil +} diff --git a/server/subsonic/users.go b/server/subsonic/users.go new file mode 100644 index 0000000..733f3fd --- /dev/null +++ b/server/subsonic/users.go @@ -0,0 +1,55 @@ +package subsonic + +import ( + "net/http" + + "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/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, + 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 +} + +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") + } + + response := newResponse() + user := buildUserResponse(loggedUser) + response.User = &user + return response, nil +} + +func (api *Router) GetUsers(r *http.Request) (*responses.Subsonic, error) { + loggedUser, ok := request.UserFrom(r.Context()) + if !ok { + return nil, newError(responses.ErrorGeneric, "Internal error") + } + + user := buildUserResponse(loggedUser) + 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 new file mode 100644 index 0000000..e41c1af --- /dev/null +++ b/server/subsonic/users_test.go @@ -0,0 +1,119 @@ +package subsonic + +import ( + "context" + "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", nil) + ctx := request.WithUser(context.Background(), 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.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.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), + ) + + 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))) + }) + }) +}) diff --git a/server/testdata/test_cert.pem b/server/testdata/test_cert.pem new file mode 100644 index 0000000..1dfa573 --- /dev/null +++ b/server/testdata/test_cert.pem @@ -0,0 +1,23 @@ +-----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 new file mode 100644 index 0000000..6f8de62 --- /dev/null +++ b/server/testdata/test_cert_encrypted.pem @@ -0,0 +1,22 @@ +-----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 new file mode 100644 index 0000000..bac61f4 --- /dev/null +++ b/server/testdata/test_key.pem @@ -0,0 +1,28 @@ +-----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 new file mode 100644 index 0000000..0ac7158 --- /dev/null +++ b/server/testdata/test_key_encrypted.pem @@ -0,0 +1,30 @@ +-----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 new file mode 100644 index 0000000..4b9215c --- /dev/null +++ b/server/testdata/test_key_encrypted_legacy.pem @@ -0,0 +1,30 @@ +-----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/tests/fake_http_client.go b/tests/fake_http_client.go new file mode 100644 index 0000000..88f0e95 --- /dev/null +++ b/tests/fake_http_client.go @@ -0,0 +1,19 @@ +package tests + +import "net/http" + +type FakeHttpClient struct { + Res http.Response + Err error + SavedRequest *http.Request + RequestCount int +} + +func (c *FakeHttpClient) Do(req *http.Request) (*http.Response, error) { + c.RequestCount++ + c.SavedRequest = req + if c.Err != nil { + return nil, c.Err + } + return &c.Res, nil +} diff --git a/tests/fixtures/#snapshot/.gitkeep b/tests/fixtures/#snapshot/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/$Recycle.Bin/.gitkeep b/tests/fixtures/$Recycle.Bin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/...unhidden_folder/.gitkeep b/tests/fixtures/...unhidden_folder/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/._02 Invisible.mp3 b/tests/fixtures/._02 Invisible.mp3 new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/.hidden_folder/.gitkeep b/tests/fixtures/.hidden_folder/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.m4a b/tests/fixtures/01 Invisible (RED) Edit Version.m4a new file mode 100644 index 0000000..005792e Binary files /dev/null and b/tests/fixtures/01 Invisible (RED) Edit Version.m4a differ diff --git a/tests/fixtures/01 Invisible (RED) Edit Version.mp3 b/tests/fixtures/01 Invisible (RED) Edit Version.mp3 new file mode 100644 index 0000000..8abd358 Binary files /dev/null and b/tests/fixtures/01 Invisible (RED) Edit Version.mp3 differ diff --git a/tests/fixtures/artist/an-album/artist.png b/tests/fixtures/artist/an-album/artist.png new file mode 100644 index 0000000..d4a3663 Binary files /dev/null and b/tests/fixtures/artist/an-album/artist.png differ diff --git a/tests/fixtures/artist/an-album/cover.jpg b/tests/fixtures/artist/an-album/cover.jpg new file mode 100644 index 0000000..6995905 Binary files /dev/null and b/tests/fixtures/artist/an-album/cover.jpg differ diff --git a/tests/fixtures/artist/an-album/front.png b/tests/fixtures/artist/an-album/front.png new file mode 100644 index 0000000..d4a3663 Binary files /dev/null and b/tests/fixtures/artist/an-album/front.png differ diff --git a/tests/fixtures/artist/an-album/test.mp3 b/tests/fixtures/artist/an-album/test.mp3 new file mode 100644 index 0000000..49518e3 Binary files /dev/null and b/tests/fixtures/artist/an-album/test.mp3 differ diff --git a/tests/fixtures/artist/artist.jpg b/tests/fixtures/artist/artist.jpg new file mode 100644 index 0000000..6995905 Binary files /dev/null and b/tests/fixtures/artist/artist.jpg differ diff --git a/tests/fixtures/bom-test.lrc b/tests/fixtures/bom-test.lrc new file mode 100644 index 0000000..223c37d --- /dev/null +++ b/tests/fixtures/bom-test.lrc @@ -0,0 +1,4 @@ +[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-utf16-test.lrc b/tests/fixtures/bom-utf16-test.lrc new file mode 100644 index 0000000..e40ea32 Binary files /dev/null and b/tests/fixtures/bom-utf16-test.lrc differ diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json new file mode 100644 index 0000000..80e439b --- /dev/null +++ b/tests/fixtures/deezer.artist.bio.json @@ -0,0 +1,9 @@ +{ + "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.</p>" + } + } + } +} diff --git a/tests/fixtures/deezer.artist.related.json b/tests/fixtures/deezer.artist.related.json new file mode 100644 index 0000000..2a55b30 --- /dev/null +++ b/tests/fixtures/deezer.artist.related.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..e3f22a1 --- /dev/null +++ b/tests/fixtures/deezer.artist.top.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..29f138d --- /dev/null +++ b/tests/fixtures/deezer.search.artist.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/empty_folder/not_an_audio_file.txt b/tests/fixtures/empty_folder/not_an_audio_file.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/ignored_folder/.ndignore b/tests/fixtures/ignored_folder/.ndignore new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/index.html b/tests/fixtures/index.html new file mode 100644 index 0000000..53915d8 --- /dev/null +++ b/tests/fixtures/index.html @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="utf-8"/> + <meta + name="description" + content="Navidrome Music Server - {{.Version}}" + /> + <title>Navidrome + + + + + + diff --git a/tests/fixtures/invalid-files/test-invalid-frame.mp3 b/tests/fixtures/invalid-files/test-invalid-frame.mp3 new file mode 100644 index 0000000..2f0fbc7 Binary files /dev/null and b/tests/fixtures/invalid-files/test-invalid-frame.mp3 differ diff --git a/tests/fixtures/itunes-library.xml b/tests/fixtures/itunes-library.xml new file mode 100644 index 0000000..ca9d154 --- /dev/null +++ b/tests/fixtures/itunes-library.xml @@ -0,0 +1,1909 @@ + + + + + Major Version1 + Minor Version1 + Application Version12.3.2.35 + Date2016-02-28T18:37:03Z + Features5 + Show Content Ratings + Library Persistent ID12B1ABAD6D8E7496 + Tracks + + 106 + + Track ID106 + Size5237173438 + Total Time7580223 + Year2011 + Date Modified2015-12-06T09:19:35Z + Date Added2013-12-30T17:34:06Z + Play Count1 + Release Date2011-11-23T08:00:00Z + Artwork Count1 + Persistent ID96070F0AB1E1AE6F + Track TypeRemote + Purchased + Movie + Has Video + NameHugo + ArtistMartin Scorsese + GenreKids & Family + KindPurchased MPEG-4 video file + Sort NameHugo + Sort ArtistMartin Scorsese + Content Ratingca-movie|PG|200| + + 108 + + Track ID108 + Size10414554 + Total Time301641 + Disc Number1 + Disc Count1 + Track Number9 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:55Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent IDC54EAA29C88CD00D + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameSleep Like a Baby Tonight + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameSleep Like a Baby Tonight + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/09%20Sleep%20Like%20a%20Baby%20Tonight.m4a + + 110 + + Track ID110 + Size6188995 + Total Time170840 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2013 + Date Modified2015-12-06T09:19:35Z + Date Added2013-12-16T19:48:06Z + Bit Rate256 + Sample Rate44100 + Release Date2013-12-13T08:00:00Z + Artwork Count1 + Persistent IDC6225CE270748558 + Track TypeRemote + Purchased + NameNo Better + ArtistLorde + Album ArtistLorde + ComposerElla Yelich O'Connor & Joel Little + AlbumNo Better - Single + GenrePop + KindPurchased AAC audio file + Sort NameNo Better + Sort AlbumNo Better - Single + Sort ArtistLorde + + 112 + + Track ID112 + Size45674908 + Total Time199765 + Disc Number1 + Disc Count1 + Track Number2 + Track Count2 + Year2013 + Date Modified2015-12-06T09:19:35Z + Date Added2013-12-16T19:48:06Z + Release Date2013-12-13T08:00:00Z + Artwork Count1 + Persistent IDD66842117A24AE76 + Track TypeRemote + Purchased + Music Video + Has Video + NameRoyals + ArtistLorde + Album ArtistLorde + AlbumNo Better - Single + GenrePop + KindPurchased MPEG-4 video file + Sort NameRoyals (TV Edit) + Sort AlbumNo Better - Single + Sort ArtistLorde + + 114 + + Track ID114 + Size4611956 + Total Time122511 + Disc Number1 + Disc Count1 + Track Number3 + Track Count101 + Year2013 + Date Modified2016-02-28T18:36:35Z + Date Added2013-12-23T02:21:40Z + Bit Rate256 + Sample Rate44100 + Release Date2013-11-29T08:00:00Z + Compilation + Artwork Count1 + Persistent ID8F5678E401FF0DEF + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameRocking Around the Christmas Tree + ArtistBrenda Lee + Album ArtistVarious Artists + Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + GenreHoliday + KindPurchased AAC audio file + Sort NameRocking Around the Christmas Tree + Sort Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + Sort ArtistBrenda Lee + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Compilations/100%20Christmas%20Hits%20&%20Xmas%20Classics_%20The%20Greatest%20Holiday%20Songs%20&%20Carols%20Collection/03%20Rocking%20Around%20the%20Christmas%20Tree.m4a + + 116 + + Track ID116 + Size6904386 + Total Time194116 + Disc Number1 + Disc Count1 + Track Number6 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:38Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent IDEDB69FA1B70C2931 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameVolcano + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameVolcano + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/06%20Volcano.m4a + + 118 + + Track ID118 + Size9336108 + Total Time265267 + Disc Number1 + Disc Count1 + Track Number8 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:52Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent ID1F82B48E9364268F + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameCedarwood Road + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameCedarwood Road + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/08%20Cedarwood%20Road.m4a + + 120 + + Track ID120 + Size8636426 + Total Time245598 + Disc Number1 + Disc Count1 + Track Number7 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:45Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent ID4549EC9A21F340E5 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameRaised By Wolves + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameRaised By Wolves + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/07%20Raised%20By%20Wolves.m4a + + 122 + + Track ID122 + Size9928436 + Total Time285842 + Disc Number1 + Disc Count1 + Track Number11 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:57Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent ID6FA96BD49EA79F4B + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameThe Troubles + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameTroubles + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/11%20The%20Troubles.m4a + + 124 + + Track ID124 + Size10715123 + Total Time305133 + Disc Number1 + Disc Count1 + Track Number10 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:54Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent ID4162CD5034AF56FC + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameThis Is Where You Can Reach Me Now + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameThis Is Where You Can Reach Me Now + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/10%20This%20Is%20Where%20You%20Can%20Reach%20Me%20Now.m4a + + 126 + + Track ID126 + Size8118374 + Date Modified2016-02-24T18:38:14Z + Date Added2014-09-05T16:58:58Z + Release Date2003-04-28T07:00:00Z + Persistent IDE9D0323956666112 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameDigital Booklet - Songs of Innocence + ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPDF document + Sort NameDigital Booklet - Songs of Innocence + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/Digital%20Booklet%20-%20Songs%20of%20Innocence.pdf + + 128 + + Track ID128 + Size8465279 + Total Time239846 + Disc Number1 + Disc Count1 + Track Number3 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:35Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent ID44D36C6A95010C83 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameCalifornia (There Is No End to Love) + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameCalifornia (There Is No End to Love) + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/03%20California%20(There%20Is%20No%20End%20to%20Love).m4a + + 130 + + Track ID130 + Size5590819 + Total Time152742 + Disc Number1 + Disc Count1 + Track Number9 + Track Count25 + Year2013 + Date Modified2016-02-28T18:37:02Z + Date Added2013-12-23T02:11:09Z + Bit Rate256 + Sample Rate44100 + Release Date2013-10-29T07:00:00Z + Artwork Count1 + Persistent ID706CE6A3FAA79EE3 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameSanta Claus Is Coming to Town + ArtistFrank Sinatra + Album ArtistFrank Sinatra, Dean Martin & Sammy Davis, Jr. + AlbumThe Rat Pack - Christmas Hits - Sinatra / Dean / Davis - The Very Best of the Ratpack at Xmas + GenreHoliday + KindPurchased AAC audio file + Sort NameSanta Claus Is Coming to Town + Sort AlbumRat Pack - Christmas Hits - Sinatra / Dean / Davis - The Very Best of the Ratpack at Xmas + Sort ArtistFrank Sinatra + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Frank%20Sinatra,%20Dean%20Martin%20&%20Sammy%20Davis,%20Jr_/The%20Rat%20Pack%20-%20Christmas%20Hits%20-%20Sinatra%20_%20Dean%20_%20Davis%20-%20The%20Very%20Best%20of%20the%20Ratpack%20at%20Xmas/09%20Santa%20Claus%20Is%20Coming%20to%20Town.m4a + + 132 + + Track ID132 + Size8842208 + Total Time252162 + Disc Number1 + Disc Count1 + Track Number2 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:32Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent IDE04FE8F487A5AD96 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameEvery Breaking Wave + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameEvery Breaking Wave + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/02%20Every%20Breaking%20Wave.m4a + + 134 + + Track ID134 + Size6797130 + Total Time190960 + Disc Number1 + Disc Count1 + Track Number6 + Track Count10 + Year2015 + Date Modified2015-03-01T02:53:40Z + Date Added2015-03-01T02:53:40Z + Bit Rate256 + Sample Rate44100 + Release Date2015-02-17T08:00:00Z + Artwork Count1 + Persistent ID61013A618EEA26CD + Track TypeRemote + Purchased + NameEvery Age + ArtistJosé González + Album ArtistJosé González + AlbumVestiges & Claws + GenreSinger/Songwriter + KindPurchased AAC audio file + Sort NameEvery Age + Sort AlbumVestiges & Claws + Sort ArtistJosé González + + 136 + + Track ID136 + Size6353549 + Total Time176781 + Disc Number1 + Disc Count1 + Track Number6 + Track Count14 + Year2013 + Date Modified2016-02-24T18:41:14Z + Date Added2013-11-21T15:26:36Z + Bit Rate256 + Sample Rate44100 + Release Date2013-10-14T07:00:00Z + Artwork Count1 + Persistent ID944816B1BA476F46 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameNew + ArtistPaul McCartney + Album ArtistPaul McCartney + ComposerPaul McCartney + AlbumNEW (Deluxe Edition) + GenreRock + KindPurchased AAC audio file + Sort NameNew + Sort AlbumNEW (Deluxe Edition) + Sort ArtistPaul McCartney + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Paul%20McCartney/NEW%20(Deluxe%20Edition)/06%20New.m4a + + 138 + + Track ID138 + Size8024225 + Total Time226763 + Disc Number1 + Disc Count1 + Track Number4 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:42Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent IDA89D879A64D75D85 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameSong for Someone + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameSong for Someone + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/04%20Song%20for%20Someone.m4a + + 140 + + Track ID140 + Size8497977 + Total Time223384 + Disc Number1 + Disc Count1 + Track Number11 + Track Count12 + Year2012 + Date Modified2016-02-24T18:41:04Z + Date Added2015-03-06T04:40:10Z + Bit Rate256 + Sample Rate44100 + Release Date2012-01-24T08:00:00Z + Artwork Count1 + Persistent ID61017FC6C16E8DEA + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameForever Young + ArtistNabby Clifford + Album ArtistNabby Clifford + AlbumSings Classics Reggae + GenreReggae + KindPurchased AAC audio file + Sort NameForever Young + Sort AlbumSings Classics Reggae + Sort ArtistNabby Clifford + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Nabby%20Clifford/Sings%20Classics%20Reggae/11%20Forever%20Young.m4a + + 142 + + Track ID142 + Size12653397 + Total Time325667 + Disc Number1 + Disc Count1 + Track Number2 + Track Count3 + Year1985 + Date Modified2015-12-06T09:19:35Z + Date Added2013-12-08T18:45:11Z + Bit Rate256 + Sample Rate44100 + Release Date1985-08-15T07:00:00Z + Artwork Count1 + Persistent IDEB73C5E49607C5A9 + Track TypeRemote + Purchased + NameBoy (Go) [feat. Michael Stipe] {Long Version} [Long Version] + ArtistGolden Palominos + Album ArtistGolden Palominos + ComposerGolden Palominos + AlbumBoy (Go) (feat. Michael Stipe) - Single + GenreRock + KindPurchased AAC audio file + Sort NameBoy (Go) [feat. Michael Stipe] {Long Version} [Long Version] + Sort AlbumBoy (Go) (feat. Michael Stipe) - Single + Sort ArtistGolden Palominos + + 144 + + Track ID144 + Size9300291 + Total Time255382 + Disc Number1 + Disc Count1 + Track Number1 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:32Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent ID25148535B44C7C57 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameThe Miracle (Of Joey Ramone) + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameMiracle (Of Joey Ramone) + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/01%20The%20Miracle%20(Of%20Joey%20Ramone).m4a + + 146 + + Track ID146 + Size4988505 + Total Time133698 + Disc Number1 + Disc Count1 + Track Number6 + Track Count101 + Year2013 + Date Modified2016-02-28T18:36:45Z + Date Added2013-12-23T02:20:49Z + Bit Rate256 + Sample Rate44100 + Release Date2013-11-29T08:00:00Z + Compilation + Artwork Count1 + Persistent ID18BAED46A23DB02C + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameFrosty the Snowman + ArtistNat "King" Cole + Album ArtistVarious Artists + Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + GenreHoliday + KindPurchased AAC audio file + Sort NameFrosty the Snowman + Sort Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + Sort ArtistNat "King" Cole + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Compilations/100%20Christmas%20Hits%20&%20Xmas%20Classics_%20The%20Greatest%20Holiday%20Songs%20&%20Carols%20Collection/06%20Frosty%20the%20Snowman.m4a + + 148 + + Track ID148 + Size11149865 + Total Time319457 + Disc Number1 + Disc Count1 + Track Number5 + Track Count11 + Year2014 + Date Modified2016-02-24T18:41:46Z + Date Added2014-09-05T16:58:58Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-09T07:00:00Z + Artwork Count1 + Persistent ID74BB39D181431EEA + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameIris (Hold Me Close) + ArtistU2 + Album ArtistU2 + AlbumSongs of Innocence + GenreRock + KindPurchased AAC audio file + Sort NameIris (Hold Me Close) + Sort AlbumSongs of Innocence + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Songs%20of%20Innocence/05%20Iris%20(Hold%20Me%20Close).m4a + + 150 + + Track ID150 + Size39877486 + Date Modified2015-12-06T09:19:35Z + Date Added2013-12-30T17:34:06Z + Play Count1 + Artwork Count1 + Persistent IDC74EE5B5AE8D4ED7 + Track TypeRemote + Purchased + Movie + NameHugo - iTunes Extras + ArtistMartin Scorsese + AlbumHugo + KindiTunes Extras + Sort NameHugo - iTunes Extras + Sort AlbumHugo + + 152 + + Track ID152 + Size9104039 + Total Time261013 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2013 + Date Modified2015-12-06T09:19:35Z + Date Added2013-12-08T18:57:48Z + Bit Rate256 + Sample Rate44100 + Release Date2013-08-23T07:00:00Z + Artwork Count1 + Persistent IDC2999C4B62742DBE + Track TypeRemote + Purchased + NameYouth + ArtistFoxes + Album ArtistFoxes + ComposerLouisa Allen & Jonny Harris + AlbumYouth - Single + GenrePop + KindPurchased AAC audio file + Sort NameYouth + Sort AlbumYouth - Single + Sort ArtistFoxes + + 154 + + Track ID154 + Size7598093 + Total Time182653 + Disc Number3 + Disc Count4 + Track Number1 + Track Count17 + Year2011 + Date Modified2015-12-06T09:19:35Z + Date Added2013-12-23T02:25:52Z + Bit Rate256 + Sample Rate44100 + Release Date2011-10-07T07:00:00Z + Compilation + Artwork Count1 + Persistent ID3E57F45DC676A87E + Track TypeRemote + Purchased + NameFeliz Navidad + ArtistJosé Feliciano + Album ArtistVarious Artists + ComposerJosé Feliciano + AlbumPure... Christmas + GenreHoliday + KindPurchased AAC audio file + Sort NameFeliz Navidad + Sort AlbumPure... Christmas + Sort ArtistJosé Feliciano + + 156 + + Track ID156 + Size5669554 + Total Time155128 + Disc Number1 + Disc Count1 + Track Number16 + Track Count101 + Year2013 + Date Modified2016-02-28T18:36:47Z + Date Added2013-12-23T02:21:13Z + Bit Rate256 + Sample Rate44100 + Release Date2013-11-29T08:00:00Z + Compilation + Artwork Count1 + Persistent ID80ECE23848FF2DC1 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameJingle Bells + ArtistFrank Sinatra + Album ArtistVarious Artists + Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + GenreHoliday + KindPurchased AAC audio file + Sort NameJingle Bells + Sort Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + Sort ArtistFrank Sinatra + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Compilations/100%20Christmas%20Hits%20&%20Xmas%20Classics_%20The%20Greatest%20Holiday%20Songs%20&%20Carols%20Collection/16%20Jingle%20Bells.m4a + + 158 + + Track ID158 + Size8334725 + Total Time237320 + Disc Number1 + Disc Count1 + Track Number1 + Track Count12 + Year2014 + Date Modified2015-12-06T09:19:35Z + Date Added2014-07-05T17:27:33Z + Bit Rate256 + Sample Rate44100 + Release Date2014-06-17T07:00:00Z + Artwork Count1 + Persistent ID5ED3E8ED15496A25 + Track TypeRemote + Purchased + NameBack in the World + ArtistDavid Gray + Album ArtistDavid Gray + AlbumMutineers (Bonus Track Version) + GenreSinger/Songwriter + KindPurchased AAC audio file + Sort NameBack in the World + Sort AlbumMutineers (Bonus Track Version) + Sort ArtistDavid Gray + + 160 + + Track ID160 + Size9334945 + Total Time245693 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2014 + Date Modified2016-02-24T18:41:23Z + Date Added2014-02-19T20:40:38Z + Bit Rate256 + Sample Rate44100 + Release Date2014-02-17T08:00:00Z + Artwork Count1 + Persistent IDAEC48F1AD5D438FA + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameNothing But Trouble + ArtistPhantogram + Album ArtistPhantogram + ComposerJosh Carter and Sarah Barthel + AlbumNothing But Trouble - Single + GenreAlternative + KindPurchased AAC audio file + Sort NameNothing But Trouble + Sort AlbumNothing But Trouble - Single + Sort ArtistPhantogram + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Phantogram/Nothing%20But%20Trouble%20-%20Single/01%20Nothing%20But%20Trouble.m4a + + 162 + + Track ID162 + Size7926732 + Total Time227274 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2014 + Date Modified2016-02-24T18:41:29Z + Date Added2014-02-04T02:57:32Z + Bit Rate256 + Sample Rate44100 + Release Date2014-02-02T08:00:00Z + Artwork Count1 + Persistent ID3CFE49FFE43E1D9E + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameInvisible (RED) Edit Version + ArtistU2 + Album ArtistU2 + ComposerPaul Hewson, David Evans, Adam Clayton & Larry Mullen, Jr. + AlbumInvisible (RED) Edit Version + GenreRock + KindPurchased AAC audio file + Sort NameInvisible (RED) Edit Version + Sort AlbumInvisible (RED) Edit Version + Sort ArtistU2 + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/U2/Invisible%20(RED)%20Edit%20Version/01%20Invisible%20(RED)%20Edit%20Version.m4a + + 164 + + Track ID164 + Size7985464 + Total Time223215 + Disc Number1 + Disc Count1 + Track Number3 + Track Count12 + Year2013 + Date Modified2015-12-06T09:19:35Z + Date Added2013-08-01T01:55:24Z + Bit Rate256 + Sample Rate44100 + Release Date2013-06-04T07:00:00Z + Artwork Count1 + Persistent ID837F86DFEF16774A + Track TypeRemote + Purchased + NameKangaroo Court + ArtistCapital Cities + Album ArtistCapital Cities + ComposerSebu Simonian & Ryan Merchant + AlbumIn a Tidal Wave of Mystery + GenreAlternative + KindPurchased AAC audio file + Sort NameKangaroo Court + Sort AlbumIn a Tidal Wave of Mystery + Sort ArtistCapital Cities + + 166 + + Track ID166 + Size5682595 + Total Time154971 + Disc Number1 + Disc Count1 + Track Number9 + Track Count101 + Year2013 + Date Modified2016-02-28T18:36:42Z + Date Added2013-12-23T02:22:31Z + Bit Rate256 + Sample Rate44100 + Release Date2013-11-29T08:00:00Z + Compilation + Artwork Count1 + Persistent ID334454E5339901B1 + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameLet It Snow, Let It Snow, Let It Snow + ArtistDean Martin + Album ArtistVarious Artists + Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + GenreHoliday + KindPurchased AAC audio file + Sort NameLet It Snow, Let It Snow, Let It Snow + Sort Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + Sort ArtistDean Martin + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Compilations/100%20Christmas%20Hits%20&%20Xmas%20Classics_%20The%20Greatest%20Holiday%20Songs%20&%20Carols%20Collection/09%20Let%20It%20Snow,%20Let%20It%20Snow,%20Let%20It%20Snow.m4a + + 168 + + Track ID168 + Size8017048 + Total Time215493 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2014 + Date Modified2015-12-06T09:19:35Z + Date Added2014-06-13T00:55:43Z + Bit Rate256 + Sample Rate44100 + Release Date2014-07-07T07:00:00Z + Artwork Count1 + Persistent ID9EEE14AC2D0859B3 + Track TypeRemote + Purchased + NameMy Silver Lining + ArtistFirst Aid Kit + Album ArtistFirst Aid Kit + ComposerKlara Söderberg & Johanna Söderberg + AlbumMy Silver Lining - Single + GenreAlternative + KindPurchased AAC audio file + Sort NameMy Silver Lining + Sort AlbumMy Silver Lining - Single + Sort ArtistFirst Aid Kit + + 170 + + Track ID170 + Size8774296 + Total Time248840 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2014 + Date Modified2015-12-06T09:19:35Z + Date Added2014-01-20T22:41:34Z + Bit Rate256 + Sample Rate44100 + Release Date2014-01-13T08:00:00Z + Artwork Count1 + Persistent ID7EDF26B92153EE10 + Track TypeRemote + Purchased + NameGlacier + ArtistJames Vincent McMorrow + Album ArtistJames Vincent McMorrow + AlbumGlacier - Single + GenreAlternative + KindPurchased AAC audio file + Sort NameGlacier + Sort AlbumGlacier - Single + Sort ArtistJames Vincent McMorrow + + 172 + + Track ID172 + Size6841297 + Total Time192227 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2014 + Date Modified2015-12-06T09:19:35Z + Date Added2014-04-05T23:17:59Z + Bit Rate256 + Sample Rate44100 + Release Date2014-03-04T08:00:00Z + Artwork Count1 + Persistent IDC5EFA8C512982DA5 + Track TypeRemote + Purchased + NameSo Bad + ArtistKandle + Album ArtistKandle + AlbumSo Bad - Single + GenreAlternative + KindPurchased AAC audio file + Sort NameSo Bad + Sort AlbumSo Bad - Single + Sort ArtistKandle + + 174 + + Track ID174 + Size959306892 + Total Time1420800 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2013 + Date Modified2015-12-06T09:19:35Z + Date Added2014-01-04T15:57:58Z + Play Count1 + Release Date2013-09-25T07:00:00Z + Artwork Count1 + Episode Order1 + Persistent ID253F413692527B65 + Track TypeRemote + Purchased + TV Show + Has Video + NamePilot + ArtistThe Michael J. Fox Show + AlbumThe Michael J. Fox Show: Free Episode + GenreComedy + KindPurchased MPEG-4 video file + Sort NamePilot + Sort AlbumMichael J. Fox Show: Free Episode + Sort ArtistThe Michael J. Fox Show + Sort SeriesMichael J. Fox Show + Content Ratingca-tv||0| + SeriesThe Michael J. Fox Show + Episode101 + + 176 + + Track ID176 + Size6878986 + Total Time186685 + Disc Number1 + Disc Count1 + Track Number2 + Track Count101 + Year2013 + Date Modified2016-02-28T18:36:42Z + Date Added2013-12-23T02:19:52Z + Bit Rate256 + Sample Rate44100 + Release Date2013-11-29T08:00:00Z + Compilation + Artwork Count1 + Persistent ID16CC2E86A969C55A + Track TypeFile + Purchased + File Folder Count5 + Library Folder Count1 + NameRudolph the Red-Nosed Reindeer + ArtistGene Autry + Album ArtistVarious Artists + Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + GenreHoliday + KindPurchased AAC audio file + Sort NameRudolph the Red-Nosed Reindeer + Sort Album100 Christmas Hits & Xmas Classics: The Greatest Holiday Songs & Carols Collection + Sort ArtistGene Autry + Locationfile:///Users/deluan/Music/iTunes/iTunes%20Media/Music/Compilations/100%20Christmas%20Hits%20&%20Xmas%20Classics_%20The%20Greatest%20Holiday%20Songs%20&%20Carols%20Collection/02%20Rudolph%20the%20Red-Nosed%20Reindeer.m4a + + 178 + + Track ID178 + Size7139981 + Total Time197733 + Disc Number1 + Disc Count1 + Track Number1 + Track Count1 + Year2014 + Date Modified2015-12-06T09:19:35Z + Date Added2014-03-18T15:57:21Z + Bit Rate256 + Sample Rate44100 + Release Date2014-03-17T07:00:00Z + Artwork Count1 + Persistent IDAF34BB6380439E6E + Track TypeRemote + Purchased + NameGlass + Artist + Album Artist + ComposerKaren Marie Ørsted, Ronni Vindahl & August Fenger + AlbumGlass - Single + GenrePop + KindPurchased AAC audio file + Sort NameGlass + Sort AlbumGlass - Single + Sort Artist + + 180 + + Track ID180 + Size7760529 + Total Time205107 + Disc Number1 + Disc Count1 + Track Number3 + Track Count10 + Year2014 + Date Modified2015-12-06T09:19:35Z + Date Added2014-10-04T19:08:21Z + Bit Rate256 + Sample Rate44100 + Release Date2014-09-16T07:00:00Z + Artwork Count1 + Persistent IDB67C9BC5A0C17540 + Track TypeRemote + Purchased + NameHigher + ArtistLia Ices + Album ArtistLia Ices + AlbumIces + GenreAlternative + KindPurchased AAC audio file + Sort NameHigher + Sort AlbumIces + Sort ArtistLia Ices + + + Playlists + + + Master + Playlist ID224 + Playlist Persistent ID2B56F906137F73AA + All Items + Visible + Name####!#### + Playlist Items + + + Track ID106 + + + Track ID108 + + + Track ID110 + + + Track ID112 + + + Track ID114 + + + Track ID116 + + + Track ID118 + + + Track ID120 + + + Track ID122 + + + Track ID124 + + + Track ID126 + + + Track ID128 + + + Track ID130 + + + Track ID132 + + + Track ID134 + + + Track ID136 + + + Track ID138 + + + Track ID140 + + + Track ID142 + + + Track ID144 + + + Track ID146 + + + Track ID148 + + + Track ID150 + + + Track ID152 + + + Track ID154 + + + Track ID156 + + + Track ID158 + + + Track ID160 + + + Track ID162 + + + Track ID164 + + + Track ID166 + + + Track ID168 + + + Track ID170 + + + Track ID172 + + + Track ID174 + + + Track ID176 + + + Track ID178 + + + Track ID180 + + + + + Playlist ID265 + Playlist Persistent ID071C96122A0AF5B8 + Distinguished Kind4 + Music + All Items + NameMusic + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAQIbEAAAAAAAAAAAAAAAAAAAAB + AAAAAAAQIbEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIIAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIIAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + Playlist Items + + + Track ID114 + + + Track ID164 + + + Track ID158 + + + Track ID166 + + + Track ID168 + + + Track ID152 + + + Track ID130 + + + Track ID156 + + + Track ID176 + + + Track ID142 + + + Track ID170 + + + Track ID154 + + + Track ID134 + + + Track ID172 + + + Track ID180 + + + Track ID110 + + + Track ID112 + + + Track ID178 + + + Track ID140 + + + Track ID146 + + + Track ID136 + + + Track ID160 + + + Track ID162 + + + Track ID144 + + + Track ID132 + + + Track ID128 + + + Track ID138 + + + Track ID148 + + + Track ID116 + + + Track ID120 + + + Track ID118 + + + Track ID108 + + + Track ID124 + + + Track ID122 + + + Track ID126 + + + + + Playlist ID303 + Playlist Persistent ID7B63B8584A2C4C4F + Distinguished Kind47 + All Items + NameMusic Videos + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAACAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIYAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIYAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + Playlist Items + + + Track ID112 + + + + + Playlist ID307 + Playlist Persistent ID9F560177C1E87027 + Distinguished Kind7 + All Items + NameRentals + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAEIAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAAEIAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AAAIAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAIAA + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID310 + Playlist Persistent IDD14375DD4B9E4D6D + Distinguished Kind2 + Movies + All Items + NameMovies + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAAAIAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIKAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIKAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + Playlist Items + + + Track ID150 + + + Track ID106 + + + + + Playlist ID315 + Playlist Persistent ID19EFCEEF3A91DF09 + Distinguished Kind48 + All Items + NameHome Videos + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAB + AAAAAAAABAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIYAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIYAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID318 + Playlist Persistent ID03AC2871D0E39D32 + Distinguished Kind3 + TV Shows + All Items + NameTV Shows + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAAEAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIKAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIKAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + Playlist Items + + + Track ID174 + + + + + Playlist ID322 + Playlist Persistent IDC21E1D511BE1A394 + Distinguished Kind10 + Podcasts + All Items + Visible + NamePodcasts + + + Playlist ID329 + Playlist Persistent ID53233B002B5A015C + Distinguished Kind31 + iTunesU + All Items + Visible + NameiTunes U + + + Playlist ID332 + Playlist Persistent IDE4AFB5DCCABD623D + Distinguished Kind5 + Audiobooks + All Items + Visible + NameAudiobooks + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAAAgAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIKAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIKAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID335 + Playlist Persistent IDF7E04333C0E8187B + Distinguished Kind57 + All Items + NameBooks + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAB + AAAAAABAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIKAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIKAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID338 + Playlist Persistent ID19494F02C64F0EE8 + Distinguished Kind59 + All Items + NamePDFs + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAB + AAAAAACAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIKAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIKAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID341 + Playlist Persistent IDFC275C4B6561FC00 + Distinguished Kind58 + All Items + NameAudiobooks + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAAAgAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AgAEAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAIKAE + AAAAAAAAAAAAAAAAAAAAAQAAAAAAIKAEAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID347 + Playlist Persistent IDE24477F50898AFE1 + Distinguished Kind26 + All Items + NameGenius + Smart Info + + AAABAwAAAAIAAAAZAAABAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + + + + Playlist ID350 + Playlist Persistent IDCA61780D87E12A2D + All Items + Name90’s Music + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAEAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAB8YAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAB88AAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgFNMc3QAAQAB + AAAAAgAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAB + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAAABAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEQAAAAAAAAAIAAAAAAAAAAA + AAAAAAAAAAEAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAA== + + + + Playlist ID353 + Playlist Persistent IDDA2FF02F1161B986 + All Items + NameClassical Music + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAU0xzdAABAAEAAAACAAAAAQAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAADwAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAABEAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAg + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABVxTTHN0AAEAAQAAABEAAAAB + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAACAEAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAABIAQwBsAGEAcwBzAGkAYwBhAGwAAAAIAQAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEABLAGwAYQBzAHMAaQBlAGsAAAAI + AQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEgBD + AGwAYQBzAHMAaQBxAHUAZQAAAAgBAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAOAEsAbABhAHMAcwBpAGsAAAAIAQAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEABDAGwAYQBzAHMAaQBjAGEAAAAI + AQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACjCv + MOkwtzDDMK8AAAAIAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAADgBDAGwA4QBzAGkAYwBhAAAACAEAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAASwBsAGEAcwBzAGkAcwBrAAAACAEAAAEAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAQwBsAOEAcwBz + AGkAYwBhAAAACAEAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAABIASwBsAGEAcwBzAGkAcwBrAHQAAAAIAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAQaBDsEMARBBEEEOARHBDUEQQQ6BDAETwAA + AAgBAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAS + AEsAbABhAHMAeQBjAHoAbgBhAAAACAEAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAABIASwBsAGEAcwBzAGkAbgBlAG4AAAAIAQAAAQAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADABLAGwAYQBzAGkAawAA + AAgBAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU + AEsAbABhAHMAcwB6AGkAawB1AHMAAAAIAQAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAFgBWAOEBfgBuAOEAIABoAHUAZABiAGEAAAAIAQAAAQAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgZDBkQGJwYz + BkoGQwZK + + + + Playlist ID356 + Playlist Persistent ID118A2BC8FD61DC59 + All Items + NameMy Top Rated + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAABAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkAAAAQAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAADwAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAA= + + + + Playlist ID359 + Playlist Persistent ID1029D9D4A0F5B782 + All Items + NameRecently Added + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAIAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABELa4tri2uLa7//////////gAAAAAACTqA + La4tri2uLa4AAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5AgAAAQAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAB + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID362 + Playlist Persistent ID0F4D40075BDCEE14 + All Items + NameRecently Played + Smart Info + + AQEAAwAAAAIAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABcAAAIAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABELa4tri2uLa7//////////gAAAAAACTqA + La4tri2uLa4AAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5AgAAAQAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAB + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + + + Playlist ID365 + Playlist Persistent ID30B71B7F238089F5 + All Items + NameTop 25 Most Played + Smart Info + + AQEBAwAAABkAAAAZAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAA== + + Smart Criteria + + U0xzdAABAAEAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADkCAAABAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAB + AAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWAAAAEAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAA + AAAAAAAA + + Playlist Items + + + Track ID150 + + + Track ID106 + + + Track ID174 + + + + + Playlist ID371 + Playlist Persistent ID090B073227DCEA80 + Distinguished Kind50 + All Items + NameBooks + + + Music Folderfile:///Users/deluan/Music/iTunes/iTunes%20Media/ + + diff --git a/tests/fixtures/lastfm.album.getinfo.empty_urls.json b/tests/fixtures/lastfm.album.getinfo.empty_urls.json new file mode 100644 index 0000000..9daad07 --- /dev/null +++ b/tests/fixtures/lastfm.album.getinfo.empty_urls.json @@ -0,0 +1 @@ +{"album":{"artist":"The Jesus and Mary Chain","listeners":"2","image":[{"size":"small","#text":""},{"size":"medium","#text":""},{"size":"large","#text":""},{"size":"extralarge","#text":""},{"size":"mega","#text":""},{"size":"","#text":""}],"mbid":"","tags":"","name":"The Definitive Less Damage And More Joy","playcount":"2","url":"https:\/\/www.last.fm\/music\/The+Jesus+and+Mary+Chain\/The+Definitive+Less+Damage+And+More+Joy"}} diff --git a/tests/fixtures/lastfm.album.getinfo.json b/tests/fixtures/lastfm.album.getinfo.json new file mode 100644 index 0000000..b906928 --- /dev/null +++ b/tests/fixtures/lastfm.album.getinfo.json @@ -0,0 +1 @@ +{"album":{"artist":"Cher","mbid":"03c91c40-49a6-44a7-90e7-a700edf97a62","tags":{"tag":[{"url":"https://www.last.fm/tag/pop","name":"pop"},{"url":"https://www.last.fm/tag/dance","name":"dance"},{"url":"https://www.last.fm/tag/90s","name":"90s"},{"url":"https://www.last.fm/tag/1998","name":"1998"},{"url":"https://www.last.fm/tag/cher","name":"cher"}]},"name":"Believe","userplaycount":0,"image":[{"size":"small","#text":"https://lastfm.freetls.fastly.net/i/u/34s/3b54885952161aaea4ce2965b2db1638.png"},{"size":"medium","#text":"https://lastfm.freetls.fastly.net/i/u/64s/3b54885952161aaea4ce2965b2db1638.png"},{"size":"large","#text":"https://lastfm.freetls.fastly.net/i/u/174s/3b54885952161aaea4ce2965b2db1638.png"},{"size":"extralarge","#text":"https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png"},{"size":"mega","#text":"https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png"},{"size":"","#text":"https://lastfm.freetls.fastly.net/i/u/300x300/3b54885952161aaea4ce2965b2db1638.png"}],"tracks":{"track":[{"streamable":{"fulltrack":"0","#text":"0"},"duration":238,"url":"https://www.last.fm/music/Cher/Believe/Believe","name":"Believe","@attr":{"rank":1},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":228,"url":"https://www.last.fm/music/Cher/Believe/The+Power","name":"The Power","@attr":{"rank":2},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":272,"url":"https://www.last.fm/music/Cher/Believe/Runaway","name":"Runaway","@attr":{"rank":3},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":237,"url":"https://www.last.fm/music/Cher/Believe/All+or+Nothing","name":"All or Nothing","@attr":{"rank":4},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":224,"url":"https://www.last.fm/music/Cher/Believe/Strong+Enough","name":"Strong Enough","@attr":{"rank":5},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":null,"url":"https://www.last.fm/music/Cher/Believe/Dov%27e+L%27amore","name":"Dov'e L'amore","@attr":{"rank":6},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":272,"url":"https://www.last.fm/music/Cher/Believe/Takin%27+Back+My+Heart","name":"Takin' Back My Heart","@attr":{"rank":7},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":304,"url":"https://www.last.fm/music/Cher/Believe/Taxi+Taxi","name":"Taxi Taxi","@attr":{"rank":8},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":263,"url":"https://www.last.fm/music/Cher/Believe/Love+Is+the+Groove","name":"Love Is the Groove","@attr":{"rank":9},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}},{"streamable":{"fulltrack":"0","#text":"0"},"duration":233,"url":"https://www.last.fm/music/Cher/Believe/We+All+Sleep+Alone","name":"We All Sleep Alone","@attr":{"rank":10},"artist":{"url":"https://www.last.fm/music/Cher","name":"Cher","mbid":"bfcc6d75-a6a5-4bc6-8282-47aec8531818"}}]},"listeners":"597578","playcount":"4419891","url":"https://www.last.fm/music/Cher/Believe","wiki":{"published":"03 Mar 2010, 16:48","summary":"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.","content":"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\".\n\nIt was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob Dickens. Upon its debut, critical reception was generally positive. Believe became Cher's most commercially-successful release, reached number one and Top 10 all over the world. In the United States, the album was released on November 10, 1998, and reached number four on the Billboard 200 chart, where it was certified four times platinum.\n\nThe album featured a change in Cher's music; in addition, Believe presented a vocally stronger Cher and a massive use of vocoder and Auto-Tune. In 1999, the album received 3 Grammy Awards nominations for \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\". Throughout 1999 and into 2000 Cher was nominated and winning many awards for the album including a Billboard Music Award for \"Female Vocalist of the Year\", Lifelong Contribution Awards and a Star on the Walk of Fame shared with former Sonny Bono. The boost in Cher's popularity led to a very successful Do You Believe? Tour.\n\nThe album was dedicated to Sonny Bono, Cher's former husband who died earlier that year from a skiing accident.\n\nCher also recorded a cover version of \"Love Is in the Air\" during early sessions for this album. Although never officially released, the song has leaked on file sharing networks.\n\nSingles\n\n\n\"Believe\"\n\"Strong Enough\"\n\"All or Nothing\"\n\"Dov'è L'Amore\" Read more on Last.fm. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.getinfo.json b/tests/fixtures/lastfm.artist.getinfo.json new file mode 100644 index 0000000..b20264d --- /dev/null +++ b/tests/fixtures/lastfm.artist.getinfo.json @@ -0,0 +1 @@ +{"artist":{"name":"U2","mbid":"a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432","url":"https://www.last.fm/music/U2","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":"3623538","playcount":"150387104"},"similar":{"artist":[{"name":"Passengers","url":"https://www.last.fm/music/Passengers","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":"INXS","url":"https://www.last.fm/music/INXS","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":"R.E.M.","url":"https://www.last.fm/music/R.E.M.","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":"Simple Minds","url":"https://www.last.fm/music/Simple+Minds","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":"Bono","url":"https://www.last.fm/music/Bono","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":"classic rock","url":"https://www.last.fm/tag/classic+rock"},{"name":"irish","url":"https://www.last.fm/tag/irish"},{"name":"pop","url":"https://www.last.fm/tag/pop"},{"name":"alternative","url":"https://www.last.fm/tag/alternative"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/U2/+wiki"}},"published":"04 Jan 2007, 05:16","summary":"U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente. Read more on Last.fm","content":"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. As vendas giram em torno dos 145 milhões de álbuns mundialmente. Já ganharam 22 Grammy's (mais do que qualquer outra banda).\n\nAlém da empreitada musical, eles são também conhecidos pela sua participação ativa em causas políticas e humanitárias, em especial o líder, Bono. Ele tem participado ativamente em várias campanhas e apelado a líderes do mundo inteiro a fim de obter apoio na sua luta contra a fome, sobretudo nos países mais pobres. Essa \"obsessão\" de Bono levou a incomodar os membros do grupo e quase levou o U2 ao fim em tempos passados. Porém, isso foi revertido e ele conta com o apoio deles na sua causa.\n\nA banda foi formada em Dublin em Outubro de 1976. Larry Mullen, Jr., com apenas 14 anos pôs um anúncio na escola à procura de músicos para uma nova banda. A resposta resultou num grupo de 5 elementos baptizado de “Feedback”, que incluía Mullen na bateria, Adam Clayton no baixo, Paul Hewson (Bono) (voz), Dave Evans (The Edge) na guitarra e o irmão de Dave, Dik, também na guitarra.\n\nApós 18 meses de ensaios os “Feedback” mudaram o nome para “The Hype”. A banda tocou com este nome num concerto para a descoberta de novos talentos em Limerick na Irlanda em 17 de Março de 1978, tendo ganho o concurso. Jackie Haden, da “CBS Records”, que fazia parte do júri, ficou impressionado com a banda tendo-lhes dado a oportunidade de gravar a sua primeira demo.\n\nO punk rocker de Dublin Steve Averill (mais conhecido como “Steve Rapid” dos “Radiators from space”) disse que os “The Hype” não prestavam, pelo menos no nome. Adam tinha ficado amigo de um roqueiro influente da época, que deu várias dicas de nomes para a banda e sugeriu também \"U2\" (que tanto pode significar \"U-2 \" - nome de um avião espião utilizado pelos EUA durante a Guerra Fria que fora abatido pela URSS poucos dias antes do nascimento de Paul Hewson-Bono, como VOCÊ TAMBÉM, já uma alusão ao engajamento da banda) que foi bem aceito.\n\nHá quem sugira que o nome “U2” é baseado na filosofia do grupo, que acredita que a audiência faz parte da música e dos espectáculos e como tal “you too” (você também) participa do espectáculo.\n\nDick saiu em Março de 1978, e a banda fez-lhe um concerto de despedida. Reduzidos a quatro elementos, lançaram o seu primeiro single em Setembro de 1979, “U2-3” de seu nome, que chegou ao topo das tabelas na Irlanda. Em Dezembro desse ano rumaram a Londres para realizar os seus primeiros concertos fora da Irlanda, não tendo conseguido grande atenção do público ou da crítica.\n\nBoy/October\n\nEm Março de 80 assinam pela Island Records que em Outubro edita o seu primeiro álbum “Boy”. Seguidamente partem para a sua primeira digressão fora do Reino Unido. Em 1981 editam o segundo álbum, intitulado “October”. Os fãs e a crítica depressa deram conta do carácter espiritual das letras da banda; Bono, The Edge e Larry, menos Adam eram cristãos assumidos, e não faziam nada para esconder tal facto. Estes 3 membros cristãos da banda juntaram-se a um grupo religioso de Dublin chamado “Shalom”, o que os levou a questionar a relação entre a fé cristã e o estilo de vida baseado no rock.\n\nWar\n\nEm 1983, o grupo edita o álbum “War” que incluía “Sunday bloody Sunday”, que falava da situação entre católicos e protestantes na Irlanda do Norte. O primeiro single do álbum, “New year’s day”, foi o primeiro êxito internacional da banda, tendo chegado a nº 10 no Reino Unido e quase entrou no top 50 dos EUA. A MTV deu grande destaque ao vídeo desta música, o que abriu as portas ao mercado americano. Pela primeira vez realizaram concertos com lotação esgotada tanto no Reino Unido como nos Estados Unidos dos quais resultaram a gravação ao vivo do EP “Under a blood red sky” e também de um vídeo.\n\nThe Unforgettable Fire\n\nA banda começou a gravação do seu quarto álbum com produção de Brian Eno e Daniel Lanois. “The Unforgettable Fire”, (assim chamado devido a uma série de pinturas feitas por sobreviventes do bombardeamento atómico de Hiroshima e Nagasaki), foi editado em 1984. A música “Pride (in the name of love)” era dedicada a Martin Luther King, e chegou a nº 5 de vendas no Reino Unido e ao top 50 nos EUA.\n\nA Rolling Stone Magazine chamou aos U2 a “Banda dos anos 80”, dizendo que para um número crescente de fãs, os U2 eram a banda mais importante, senão mesmo, a única importante.\n\nLive Aid\n\nO concerto Live Aid, de ajuda contra a fome na Etiópia em 1985, foi visto por mais de um bilhão de pessoas por todo o mundo. Embora os U2 não fossem considerados como “banda de cartaz”, a sua versão de 13 minutos de “Bad” tomou conta do espectáculo, com Bono a abandonar o palco para ir dançar com uma espectadora. Em 1986, os U2 participaram também numa série de concertos de apoio à Amnistia Internacional, esgotando arenas e estádios por todo o mundo. Como consequência disto a Amnistia Internacional viu triplicar o seu número de membros.\n\nThe Joshua Tree\n\nEm 1987 foi editado “The Joshua tree”. O álbum entrou directamente para número 1 de vendas no Reino Unido e depressa chegou ao mesmo lugar nos EUA. Os singles “With or without you” e “I still haven’t found what I’m looking for” tiveram o mesmo êxito. Foram a quarta banda a ter direito a uma capa da Time Magazine, (as outras três tinham sido os Beatles, The Band e The Who).\n\nA banda gravou e filmou diversos concertos da digressão de “Joshua tree” para o álbum e documentário “Rattle and Hum” em 1988 e editado em vídeo em 1989. O álbum tornou-se num tributo à música americana, tendo sido gravado nos estúdios da “Sun” em Memphis, gravado com Bob Dylan e B.B. King e cantado sobre a grande Billie Holiday.\n\nApesar de ter tido uma recepção positiva por parte dos fãs, a crítica considerou-o pretensioso, por pretender equiparar os U2 aos grandes nomes da música.\n\nAchtung Baby, Zooropa, ZooTV\n\nApós um período de férias a banda juntou-se em Berlim, nos fins de 1990 para começar a trabalhar no seu próximo álbum de estúdio; uma vez mais Brian Eno e Daniel Lanois foram escolhidos para o produzir. As primeiras sessões não terão corrido muito bem, mas a banda conseguiu mesmo assim apresentar o álbum em Novembro de 1991. Carregado de distorções e experimentalismo, o álbum foi muito bem recebido pelos fãs e crítica especializada.Achtung Baby é até hoje considerado um dos melhores álbuns do U2.Bono diz,a respeito do álbum,que é \"o som de 4 homens derrubando o Joshua Tree a machadadas\"\n\nNos princípios de 1992, começaram a digressão por terras americanas, chegando a ter a participação de Axl Rose em um dos shows. O espectáculo de multimédia, conhecido como “Zoo TV”, confundia as audiências, com centenas de ecrãs de vídeo, carros voadores e personagens como “The Fly”. Esta digressão era uma tentativa dos U2 gozarem com os excessos do rock, de forma a parecer que tinham abraçado a ganância e a decadência – por vezes mesmo fora do palco. Muitos não terão percebido isso, e pensaram que os U2 tinham perdido a chama. Seguindo o mesmo tema, voltaram a estúdio – num intervalo da digressão de “Zoo TV” – e gravaram “Zooropa” que foi editado em Julho de 1993.O suco de limão de \"Lemon\" (no início dos shows eles saíam de dentro de um limão gigante)e a coreografia estabanada em \"Discoteque\"(Principalmente de Larry Mullen Jr.,visivelmente consrangido em participar de tamanho mico)foram um fiasco total nos EUA.\n\nApós algum tempo de descanso e de participarem em projectos paralelos como as bandas sonoras de “Batman forever” e “Mission Impossible\", a banda editou um álbum experimental chamado “Original Soundtracks I”. Este álbum, que incluía a participação de Luciano Pavarotti em “Miss Sarajevo”, não teve muita divulgação e como tal, recebeu pouca atenção quer da crítica, tampouco do público.Larry Mullen Jr. o considera de longe,\"O pior trabalho do U2.Pior até mesmo que 'Pop'\"\n\nPOP e Popmart\n\nNo início de 1996 começaram a trabalhar no seu novo álbum. “Pop” foi editado em Março de 1997. O álbum chegou a 1º lugar de vendas em 28 países, mas teve críticas bastantes variadas, havendo quem achasse que a indústria musical tinha passado os limites da tolerância na promoção de “Pop”.\n\nAll That You Can't Leave Behind\n\nNo início de 1999 os U2 voltam ao estúdio novamente com Brian Eno e Daniel Lanois na produção. Depois da extravagante digressão “PopMart”, a crítica achou que os U2 estavam a tentar voltar aos tempos de “The Joshua Tree” por forma a tentar manter a sua legião de fãs. Durante as gravações a banda colaborou com o autor Salman Rushdie que escreveu a letra para a música de “The ground beneath her feet” baseado no seu livro com o mesmo nome. Essa e outras músicas fizeram parte da banda sonora do filme “The Million Dollar Hotel”, baseado numa história escrita por Bono, e realizado por Wim Wenders, velho conhecido da banda.\n\n“All That You Can't Leave Behind” foi editado em Outubro e foi muito bem recebido, sendo considerado por muitos, (Rolling Stone incluída) a terceira obra-prima dos U2 ao lado de “Actung Baby” e “The Joshua Tree”. Chegou ao nº 1 de vendas em 31 países; o single “Beautiful Day” foi também um êxito por todo o mundo, tendo inclusive ganho três Grammys. A digressão que se seguiu, chamada “The Elevation Tour” quase foi cancelada devido ao ataque terrorista de 11 de Setembro, mas eles decidiram continuar, acabando por ser considerada a 2ª maior digressão de sempre (em termos de receitas), logo a seguir a “Voodoo Lounge Tour” dos Rolling Stones em 1994. Após o enorme sucesso do álbum e da digressão, muitos fãs da banda consideraram que ela podia ser considerada a “maior banda de rock do mundo” conforme tinha sido dito por Bono um ano antes.\n\nApós o fim da digressão em 2001, os U2 tocaram três músicas em Nova Orleães durante o intervalo do Super Bowl XXXVI. Numa perfomance emocional de \"Where The Streets Have No Name\", o nome das vítimas do ataque de 11 de setembro, projectados numa cortina, flutuavam em direção ao céu atrás da banda. No fim da actuação, Bono abriu o seu casaco e revelou uma bandeira americana pintada no tecido. Essa imagem apareceria na capa de inúmeros jornais e revistas. Poucos meses depois, \"All That You Can't Leave Behind\" ganhou mais quatro Grammy Awards.\n\nBono continuou a sua campanha pelo perdão da dívida dos países mais pobres durante o verão de 2002.\n\nNo final de 2002, os U2 lançaram a segunda parte da sua colecção de grandes hits, o The Best of 1990-2000.\n\nOs artistas de dance music LMC sampleou \"With Or Without\" na faixa \"Take Me to The Clouds Above\" que ainda incluiu letras de \"How Will I Know\" de Whitney Houston. Todos os quatro membros dos U2 autorizaram a faixa, que foi lançada com o título de LMC vs U2. Adam Clayton disse sobre a faixa: \"É uma óptima batida e você pode dançar com ela. Eu gosto especialmente do baixo.\" A faixa liderou o top de singles do Reino Unido em Fevereiro de 2004, o top 5 da Irlanda e o top 10 da Austrália.\n\nEm Abril de 2004, a revista Rolling Stone colocou os U2 entre os 50 \"maiores artistas de rock & roll de todos os tempos\".\n\nUma cópia não finalizada do novo álbum da banda foi roubado em Nice, França, em Julho de 2004. Em suma,o álbum foi bem recebido em vários países,como o Reino Unido,mas nos EUA foi recebido com frieza. O pesadelo \"Pop\" ainda rondava por lá.\n\n\nO álbum How to Dismantle an Atomic Bomb foi lançado no dia 22 de novembro (23 de novembro nos Estados Unidos); entretanto, em 22 de julho Bono revelou que caso o disco fosse disponibilizado em redes P2P, ele seria lançado imediatamente através do iTunes e estaria nas lojas num mês. O primeiro single do álbum, \"Vertigo\", foi lançado no dia 24 de setembro de 2004. A música foi bastante tocada nas rádios e na primeira semana do seu lançamento estreou no 18º lugar na \"Modern Rock Tracks chart\" da Billboard e em 46º lugar na \"Billboard Hot 100\"\n\nEm julho de 2006 a banda anunciou que iria começar a trabalhar no seu novo albúm, entretanto, as sessões não foram marcadas. A previsão é que o álbum não sairira antes do fim de 2007. Durante esse período de hiato, o U2 porém não parou de trabalhar. Em 2006, fizeram uma cover de \"The Saints Are Coming\" da banda escocesa The Skids juntamente com o Green Day, o single da música foi lançado e seus lucros revertidos para as vítimas do furacão Katrina que devastou os EUA naquele ano. As duas bandas fizerem uma apresentação ao vivo na reinauguração do SuperDome, estádio que também sofreu os esfeitos do furacão e serviu como abrigo para as pessoas vitimadas em Nova Orleans. Mais tarde, em Novembro do mesmo ano, a banda lançou a coletânea \"U218\", compilação de seus grandes sucessoas abrangendo desde \"Boy\" até o então último trabalho \"How to Dismantle an Atomic Bomb\", além de duas inéditas a já citada \"The Saints....\" e \"Window in the Skies\", ambas acabaram por gerar novos vídeos para banda.\n\nApesar de nehum lançamento oficial nesse período os integranes da banda não pararam de trabalhar. Bono continuou envolvido em suas campanhas humanitárias e juntamente com The Edge realizam toda produção da trilha do musical do Homem Aranha que deve estreiar na Broadway em 2010. No fim de 2008, atiçando mais ainda a curiosidade dos seus fãs para o novo disco, o U2 lançou uma cover de \"I Believe in Father Christmas\" de Greg Lake (Emerson Lake & Palmer), para a Red Campaign juntamente com um clipe para mesma.\n\n\nNo Line on The Horizon\n\nApós 5 anosde espera, finalmente em Março de 2009 chega as lojas do mundo todo \"No Line on The Horizon\", que apesar de ter seulançamento vazado na internet devido a um descuido da própria gravadora da banda na Nova Zelândia, faz com que o U2 alcance o topo das vendas em diversos países do mundo, incluindo Estados Unidos, Reino Unido, Brasil, Portugal, Irlanda dentre outros.\n\nApós algumas sessões de gravações com o produtor Rick Rubin - produtor de Justin Timberlake entre outros nomes da músic americana - O U2 não gostou muito do que havia feito e decidiu reeditar um parceria de sucesso: chamaram Brian Eno e Daniel Lanois para ajudar neste trabalho. As novas sessões de gravação ocorrem no Marrocos e em Dublin, com o U2 buscando sua reinvenção como sempre relevavam nas declarações feitas a imprensa. \"O sentimento geral do novo álbum é que está cheio de ideias nunca antes ouvidas num álbum dos U2\" - declarou The Edge.\n\n\"Get on Your Boots\" primeiro single do trabalho pegou os fãs a banda de surpesa ao ser lançado nas rádios do mundo em 23/01/2009. Certamente essa era a úsica mais rápida que eles já haviam feito, com uma batida funk e excelentes riffs de The Edge, realmente era a chegada deles na nova era! Bono declara na música \"Future Needs a big kiss\" e pelo jeito encarar o futuro sem medo e com dignidade é tudo o que eles querem. A primeira apresentação ao vivo da banda ocorreu na edição do Grammy onde eles fizeram a abertura da cerimônia.\n\nPor ocasião do lançamento do CD o U2 realizou trabalho de divulgação em diversos lugares, repetindo a estratégia do CD anterior no qual fizeram um show surpresa em Nova York, tocaram no alto dos estúdio da BBC em Londres para uma legião de fãs. Durante uma semana em março apareceram diariamente no programa de David Letterman, o Late Showda CBS norte americana, tocando suas novas músicas e antigos sucessos. Apresentaram-se também no Echo Awards na Alemanha dentro outras aparições especiais.\n\nE para os fãs a boa notícia é que os irlandeses pretendem dentro em breve lançar mais um outro álbum com material sobressalente de \"No Line...\" : \"Songs of Ascent\", segundo Bono o novo trabalho \"será mais arrebatador e meditativo, mas sem ser complacente\", e deve chegar as lojas ainda em 2009.\n\nEste álbum deu início à turnê 360°, com um palco no centro dos estádios com visão de 360 graus tanto do palco quanto do telão, onde a bateria se move e existem pontes que levam a um raio externo por onde circulam os músicos. Um espetáculo visual do nível dos shows da banda ao longo dos anos 90, pela sua magnitude. Read more on Last.fm. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.getinfo.unknown.json b/tests/fixtures/lastfm.artist.getinfo.unknown.json new file mode 100644 index 0000000..c3ef9bc --- /dev/null +++ b/tests/fixtures/lastfm.artist.getinfo.unknown.json @@ -0,0 +1 @@ +{"artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D","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":"2774837","playcount":"137106224"},"similar":{"artist":[]},"tags":{"tag":[{"name":"mysterious","url":"https://www.last.fm/tag/mysterious"},{"name":"mistagged artist","url":"https://www.last.fm/tag/mistagged+artist"},{"name":"unknown","url":"https://www.last.fm/tag/unknown"},{"name":"rock","url":"https://www.last.fm/tag/rock"},{"name":"Soundtrack","url":"https://www.last.fm/tag/Soundtrack"}]},"bio":{"links":{"link":{"#text":"","rel":"original","href":"https://last.fm/music/%5Bunknown%5D/+wiki"}},"published":"10 Feb 2006, 20:25","summary":"[unknown] is a standard artist name used at MusicBrainz for indicating where an artist name is lacking or not provided.\n\n--\n\nFor the short-lived visual-kei band, see *\n\n--\n\nThere are other artists with this or a similar spelling, usually their scrobbles will be filtered when submitted unless they are whitelisted. Read more on Last.fm","content":"[unknown] is a standard artist name used at MusicBrainz for indicating where an artist name is lacking or not provided.\n\n--\n\nFor the short-lived visual-kei band, see *\n\n--\n\nThere are other artists with this or a similar spelling, usually their scrobbles will be filtered when submitted unless they are whitelisted. Read more on Last.fm. User-contributed text is available under the Creative Commons By-SA License; additional terms may apply."}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.getsimilar.json b/tests/fixtures/lastfm.artist.getsimilar.json new file mode 100644 index 0000000..cca4f16 --- /dev/null +++ b/tests/fixtures/lastfm.artist.getsimilar.json @@ -0,0 +1 @@ +{"similarartists":{"artist":[{"name":"Passengers","mbid":"e110c11f-1c94-4471-a350-c38f46b29389","match":"1","url":"https://www.last.fm/music/Passengers","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"},{"name":"INXS","mbid":"481bf5f9-2e7c-4c44-b08a-05b32bc7c00d","match":"0.511468","url":"https://www.last.fm/music/INXS","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"}],"@attr":{"artist":"U2"}}} diff --git a/tests/fixtures/lastfm.artist.getsimilar.unknown.json b/tests/fixtures/lastfm.artist.getsimilar.unknown.json new file mode 100644 index 0000000..beb2254 --- /dev/null +++ b/tests/fixtures/lastfm.artist.getsimilar.unknown.json @@ -0,0 +1 @@ +{"similarartists":{"artist":[],"@attr":{"artist":"[unknown]"}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.gettoptracks.json b/tests/fixtures/lastfm.artist.gettoptracks.json new file mode 100644 index 0000000..7ed558d --- /dev/null +++ b/tests/fixtures/lastfm.artist.gettoptracks.json @@ -0,0 +1 @@ +{"toptracks":{"track":[{"name":"Beautiful Day","playcount":"6309776","listeners":"1037970","mbid":"f7f264d0-a89b-4682-9cd7-a4e7c37637af","url":"https://www.last.fm/music/U2/_/Beautiful+Day","streamable":"0","artist":{"name":"U2","mbid":"a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432","url":"https://www.last.fm/music/U2"},"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"}],"@attr":{"rank":"1"}},{"name":"With or Without You","playcount":"6779665","listeners":"1022929","mbid":"6b9a509f-6907-4a6e-9345-2f12da09ba4b","url":"https://www.last.fm/music/U2/_/With+or+Without+You","streamable":"0","artist":{"name":"U2","mbid":"a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432","url":"https://www.last.fm/music/U2"},"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"}],"@attr":{"rank":"2"}}],"@attr":{"artist":"U2","page":"1","perPage":"2","totalPages":"166117","total":"332234"}}} diff --git a/tests/fixtures/lastfm.artist.gettoptracks.unknown.json b/tests/fixtures/lastfm.artist.gettoptracks.unknown.json new file mode 100644 index 0000000..867e498 --- /dev/null +++ b/tests/fixtures/lastfm.artist.gettoptracks.unknown.json @@ -0,0 +1 @@ +{"toptracks":{"track":[{"name":"Spur 1","playcount":"291787","listeners":"74049","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+1","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"1"}},{"name":"Spur 2","playcount":"164676","listeners":"47124","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+2","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"2"}},{"name":"Pista 1","playcount":"161068","listeners":"45166","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+1","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"3"}},{"name":"Spur 3","playcount":"149221","listeners":"44548","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+3","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"4"}},{"name":"Spur 4","playcount":"128418","listeners":"37789","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+4","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"5"}},{"name":"Spur 5","playcount":"117667","listeners":"35249","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+5","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"6"}},{"name":"Pista 2","playcount":"126862","listeners":"33541","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+2","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"7"}},{"name":"Spur 6","playcount":"110392","listeners":"33291","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+6","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"8"}},{"name":"Spur 7","playcount":"103816","listeners":"31810","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+7","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"9"}},{"name":"Pista 3","playcount":"111927","listeners":"30448","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+3","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"10"}},{"name":"Spur 8","playcount":"97979","listeners":"30048","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+8","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"11"}},{"name":"Pista 4","playcount":"107118","listeners":"29050","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+4","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"12"}},{"name":"Spur 9","playcount":"91543","listeners":"28757","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+9","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"13"}},{"name":"01","playcount":"79506","listeners":"28280","url":"https://www.last.fm/music/%5Bunknown%5D/_/01","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"14"}},{"name":"Spur 10","playcount":"85491","listeners":"27448","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+10","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"15"}},{"name":"Pista 5","playcount":"99477","listeners":"27249","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+5","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"16"}},{"name":"Pista 6","playcount":"97264","listeners":"26630","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+6","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"17"}},{"name":"Titelnummer 1","playcount":"98813","listeners":"26610","url":"https://www.last.fm/music/%5Bunknown%5D/_/Titelnummer+1","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"18"}},{"name":"Spur 11","playcount":"76938","listeners":"25496","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+11","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"19"}},{"name":"Intro","playcount":"61452","listeners":"25255","mbid":"013b9dd9-d08d-4cc6-bc9b-8f7347083341","url":"https://www.last.fm/music/%5Bunknown%5D/_/Intro","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"20"}},{"name":"Pista 7","playcount":"86927","listeners":"25085","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+7","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"21"}},{"name":"Pista 8","playcount":"83547","listeners":"24045","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+8","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"22"}},{"name":"Spur 12","playcount":"68679","listeners":"23411","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+12","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"23"}},{"name":"Pista 9","playcount":"79313","listeners":"23000","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+9","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"24"}},{"name":"Pista 10","playcount":"75757","listeners":"22733","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+10","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"25"}},{"name":"Pista 11","playcount":"69648","listeners":"21167","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+11","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"26"}},{"name":"Spur 13","playcount":"58421","listeners":"20856","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+13","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"27"}},{"name":"Piste 4","playcount":"53543","listeners":"20606","url":"https://www.last.fm/music/%5Bunknown%5D/_/Piste+4","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"28"}},{"name":"Pista 12","playcount":"61278","listeners":"19455","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+12","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"29"}},{"name":"Piste 1","playcount":"76129","listeners":"19209","url":"https://www.last.fm/music/%5Bunknown%5D/_/Piste+1","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"30"}},{"name":"Raita 1","playcount":"88017","listeners":"19106","url":"https://www.last.fm/music/%5Bunknown%5D/_/Raita+1","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"31"}},{"name":"Opening","playcount":"44282","listeners":"18506","mbid":"0423def3-23f3-4498-b5c0-1830197b7f9f","url":"https://www.last.fm/music/%5Bunknown%5D/_/Opening","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"32"}},{"name":"Spur 14","playcount":"47807","listeners":"18389","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+14","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"33"}},{"name":"Ścieżka 1","playcount":"65482","listeners":"18081","url":"https://www.last.fm/music/%5Bunknown%5D/_/%C5%9Acie%C5%BCka+1","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"34"}},{"name":"Pista 13","playcount":"53534","listeners":"17355","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+13","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"35"}},{"name":"-","playcount":"119343","listeners":"17348","url":"https://www.last.fm/music/%5Bunknown%5D/_/-","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"36"}},{"name":"トラック 1","playcount":"95172","listeners":"16666","url":"https://www.last.fm/music/%5Bunknown%5D/_/%E3%83%88%E3%83%A9%E3%83%83%E3%82%AF+1","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"37"}},{"name":"Titelnummer 2","playcount":"53170","listeners":"16333","url":"https://www.last.fm/music/%5Bunknown%5D/_/Titelnummer+2","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"38"}},{"name":"No01","playcount":"57466","listeners":"16007","url":"https://www.last.fm/music/%5Bunknown%5D/_/No01","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"39"}},{"name":"Spur 15","playcount":"39049","listeners":"15952","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+15","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"40"}},{"name":"Ścieżka 2","playcount":"69814","listeners":"15943","url":"https://www.last.fm/music/%5Bunknown%5D/_/%C5%9Acie%C5%BCka+2","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"41"}},{"name":"02","playcount":"54648","listeners":"15314","url":"https://www.last.fm/music/%5Bunknown%5D/_/02","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"42"}},{"name":"Pista 14","playcount":"50241","listeners":"15101","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+14","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"43"}},{"name":"Piste 2","playcount":"52199","listeners":"14815","url":"https://www.last.fm/music/%5Bunknown%5D/_/Piste+2","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"44"}},{"name":"No02","playcount":"50956","listeners":"14543","url":"https://www.last.fm/music/%5Bunknown%5D/_/No02","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"45"}},{"name":"Titelnummer 3","playcount":"46949","listeners":"14474","url":"https://www.last.fm/music/%5Bunknown%5D/_/Titelnummer+3","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"46"}},{"name":"03","playcount":"48350","listeners":"14072","url":"https://www.last.fm/music/%5Bunknown%5D/_/03","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"47"}},{"name":"Spur 16","playcount":"32775","listeners":"13720","url":"https://www.last.fm/music/%5Bunknown%5D/_/Spur+16","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"48"}},{"name":"Titelnummer 4","playcount":"42664","listeners":"13379","url":"https://www.last.fm/music/%5Bunknown%5D/_/Titelnummer+4","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"49"}},{"name":"Pista 15","playcount":"43611","listeners":"13305","url":"https://www.last.fm/music/%5Bunknown%5D/_/Pista+15","streamable":"0","artist":{"name":"[unknown]","mbid":"5dfdca28-9ddc-4853-933c-8bc97d87beec","url":"https://www.last.fm/music/%5Bunknown%5D"},"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"}],"@attr":{"rank":"50"}}],"@attr":{"artist":"[unknown]","page":"1","perPage":"50","totalPages":"270172","total":"13508587"}}} \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.page.html b/tests/fixtures/lastfm.artist.page.html new file mode 100644 index 0000000..1922e31 --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/lastfm.artist.page.ignored.html b/tests/fixtures/lastfm.artist.page.ignored.html new file mode 100644 index 0000000..96eda23 --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.ignored.html @@ -0,0 +1,7 @@ + + + + + + + \ 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 new file mode 100644 index 0000000..aa7b9c9 --- /dev/null +++ b/tests/fixtures/lastfm.artist.page.no_meta.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/tests/fixtures/listenbrainz.nowplaying.request.json b/tests/fixtures/listenbrainz.nowplaying.request.json new file mode 100644 index 0000000..a9c5def --- /dev/null +++ b/tests/fixtures/listenbrainz.nowplaying.request.json @@ -0,0 +1,24 @@ +{ + "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 + } + } + } + ] +} diff --git a/tests/fixtures/listenbrainz.scrobble.request.json b/tests/fixtures/listenbrainz.scrobble.request.json new file mode 100644 index 0000000..f666777 --- /dev/null +++ b/tests/fixtures/listenbrainz.scrobble.request.json @@ -0,0 +1,25 @@ +{ + "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 + } + } + } + ] +} diff --git a/tests/fixtures/mixed-lyrics.flac b/tests/fixtures/mixed-lyrics.flac new file mode 100644 index 0000000..d048234 Binary files /dev/null and b/tests/fixtures/mixed-lyrics.flac differ diff --git a/tests/fixtures/no_replaygain.mp3 b/tests/fixtures/no_replaygain.mp3 new file mode 100644 index 0000000..45c2176 Binary files /dev/null and b/tests/fixtures/no_replaygain.mp3 differ diff --git a/tests/fixtures/playlists/bom-test-utf16.m3u b/tests/fixtures/playlists/bom-test-utf16.m3u new file mode 100644 index 0000000..9c2e9d5 Binary files /dev/null and b/tests/fixtures/playlists/bom-test-utf16.m3u differ diff --git a/tests/fixtures/playlists/bom-test.m3u b/tests/fixtures/playlists/bom-test.m3u new file mode 100644 index 0000000..f5a0080 --- /dev/null +++ b/tests/fixtures/playlists/bom-test.m3u @@ -0,0 +1,6 @@ +#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/cr-ended.m3u b/tests/fixtures/playlists/cr-ended.m3u new file mode 100644 index 0000000..a9d4cb2 --- /dev/null +++ b/tests/fixtures/playlists/cr-ended.m3u @@ -0,0 +1 @@ +# This is a comment abc.mp3 def.mp3 \ No newline at end of file diff --git a/tests/fixtures/playlists/invalid_json.nsp b/tests/fixtures/playlists/invalid_json.nsp new file mode 100644 index 0000000..7fd1e7b --- /dev/null +++ b/tests/fixtures/playlists/invalid_json.nsp @@ -0,0 +1,42 @@ +{ + "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/lf-ended.m3u b/tests/fixtures/playlists/lf-ended.m3u new file mode 100644 index 0000000..87cc604 --- /dev/null +++ b/tests/fixtures/playlists/lf-ended.m3u @@ -0,0 +1,3 @@ +# This is a comment +abc.mp3 +def.mp3 \ No newline at end of file diff --git a/tests/fixtures/playlists/pls-with-name.m3u b/tests/fixtures/playlists/pls-with-name.m3u new file mode 100644 index 0000000..a214b70 --- /dev/null +++ b/tests/fixtures/playlists/pls-with-name.m3u @@ -0,0 +1,4 @@ +#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 new file mode 100644 index 0000000..d665c89 --- /dev/null +++ b/tests/fixtures/playlists/pls-without-name.m3u @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..98e6d96 --- /dev/null +++ b/tests/fixtures/playlists/pls1.m3u @@ -0,0 +1,2 @@ +test.mp3 +test.ogg \ No newline at end of file diff --git a/tests/fixtures/playlists/recently_played.nsp b/tests/fixtures/playlists/recently_played.nsp new file mode 100644 index 0000000..08fe685 --- /dev/null +++ b/tests/fixtures/playlists/recently_played.nsp @@ -0,0 +1,14 @@ +/* + 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 new file mode 100644 index 0000000..af745ba --- /dev/null +++ b/tests/fixtures/playlists/subfolder1/.hidden_playlist1.m3u @@ -0,0 +1,2 @@ +test.mp3 +test.ogg diff --git a/tests/fixtures/playlists/subfolder1/pls1.m3u b/tests/fixtures/playlists/subfolder1/pls1.m3u new file mode 100644 index 0000000..af745ba --- /dev/null +++ b/tests/fixtures/playlists/subfolder1/pls1.m3u @@ -0,0 +1,2 @@ +test.mp3 +test.ogg diff --git a/tests/fixtures/playlists/subfolder2/.hidden_playlist2.m3u b/tests/fixtures/playlists/subfolder2/.hidden_playlist2.m3u new file mode 100644 index 0000000..af745ba --- /dev/null +++ b/tests/fixtures/playlists/subfolder2/.hidden_playlist2.m3u @@ -0,0 +1,2 @@ +test.mp3 +test.ogg diff --git a/tests/fixtures/playlists/subfolder2/pls2.m3u b/tests/fixtures/playlists/subfolder2/pls2.m3u new file mode 100644 index 0000000..cfe6994 --- /dev/null +++ b/tests/fixtures/playlists/subfolder2/pls2.m3u @@ -0,0 +1,4 @@ +../test.mp3 +../test.ogg +/tests/fixtures/01%20Invisible%20(RED)%20Edit%20Version.mp3 +/invalid/path/xyz.mp3 \ No newline at end of file diff --git a/tests/fixtures/robots.txt b/tests/fixtures/robots.txt new file mode 100644 index 0000000..9424408 --- /dev/null +++ b/tests/fixtures/robots.txt @@ -0,0 +1,4 @@ +User-agent: bingbot +Disallow: /manifest.webmanifest + +User-agent: * diff --git a/tests/fixtures/spotify.search.artist.json b/tests/fixtures/spotify.search.artist.json new file mode 100644 index 0000000..961c763 --- /dev/null +++ b/tests/fixtures/spotify.search.artist.json @@ -0,0 +1,638 @@ +{ +"artists": { +"href": "https://api.spotify.com/v1/search?query=U2&type=artist&offset=0&limit=20", +"items": [ +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/51Blml2LZPmy7TTiAg47vQ" +}, +"followers": { +"href": null, +"total": 7369641 +}, +"genres": [ +"irish rock", +"permanent wave", +"rock" +], +"href": "https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ", +"id": "51Blml2LZPmy7TTiAg47vQ", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534", +"width": 160 +} +], +"name": "U2", +"popularity": 82, +"type": "artist", +"uri": "spotify:artist:51Blml2LZPmy7TTiAg47vQ" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/6Yi6ndhYVLUaYu7rEqUCPT" +}, +"followers": { +"href": null, +"total": 1008 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/6Yi6ndhYVLUaYu7rEqUCPT", +"id": "6Yi6ndhYVLUaYu7rEqUCPT", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b2734dc59f13a52e236c404b8abf", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e024dc59f13a52e236c404b8abf", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d000048514dc59f13a52e236c404b8abf", +"width": 64 +} +], +"name": "U2R", +"popularity": 1, +"type": "artist", +"uri": "spotify:artist:6Yi6ndhYVLUaYu7rEqUCPT" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/5TucOfYYQ8HPdDdvsQZAZe" +}, +"followers": { +"href": null, +"total": 658 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/5TucOfYYQ8HPdDdvsQZAZe", +"id": "5TucOfYYQ8HPdDdvsQZAZe", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b273931ae74e023fcb999dc423a5", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e02931ae74e023fcb999dc423a5", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d00004851931ae74e023fcb999dc423a5", +"width": 64 +} +], +"name": "U2KUSHI", +"popularity": 2, +"type": "artist", +"uri": "spotify:artist:5TucOfYYQ8HPdDdvsQZAZe" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/5s3rOzCczqCQrvueHRCZOx" +}, +"followers": { +"href": null, +"total": 44 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/5s3rOzCczqCQrvueHRCZOx", +"id": "5s3rOzCczqCQrvueHRCZOx", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/c474959b393e2cf05bec6deb83643b65b12cf258", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/36ea6b9246b8dfe59288f826cfeaf9cf641e7316", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/709c8f24166781c1a0695046e757e1f4f6e1ac34", +"width": 160 +} +], +"name": "U2funnyTJ", +"popularity": 6, +"type": "artist", +"uri": "spotify:artist:5s3rOzCczqCQrvueHRCZOx" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/4CWC85PCLJ0yzPeJYXnQOG" +}, +"followers": { +"href": null, +"total": 908 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/4CWC85PCLJ0yzPeJYXnQOG", +"id": "4CWC85PCLJ0yzPeJYXnQOG", +"images": [], +"name": "U2 Rocks", +"popularity": 0, +"type": "artist", +"uri": "spotify:artist:4CWC85PCLJ0yzPeJYXnQOG" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/21114frei5NgrkMuLn6AOz" +}, +"followers": { +"href": null, +"total": 0 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/21114frei5NgrkMuLn6AOz", +"id": "21114frei5NgrkMuLn6AOz", +"images": [], +"name": "U2A9F", +"popularity": 0, +"type": "artist", +"uri": "spotify:artist:21114frei5NgrkMuLn6AOz" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/3dhoDqkI6atVLE43nkx8VZ" +}, +"followers": { +"href": null, +"total": 878 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/3dhoDqkI6atVLE43nkx8VZ", +"id": "3dhoDqkI6atVLE43nkx8VZ", +"images": [], +"name": "LMC vs U2", +"popularity": 14, +"type": "artist", +"uri": "spotify:artist:3dhoDqkI6atVLE43nkx8VZ" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/5bi7xpKp2mDDSnFfQkBEjR" +}, +"followers": { +"href": null, +"total": 989 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/5bi7xpKp2mDDSnFfQkBEjR", +"id": "5bi7xpKp2mDDSnFfQkBEjR", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b2735931f4613d57703ef50ff0e4", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e025931f4613d57703ef50ff0e4", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d000048515931f4613d57703ef50ff0e4", +"width": 64 +} +], +"name": "U21", +"popularity": 0, +"type": "artist", +"uri": "spotify:artist:5bi7xpKp2mDDSnFfQkBEjR" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/3H7E05uiFuqgwBQrXFaQIm" +}, +"followers": { +"href": null, +"total": 18 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/3H7E05uiFuqgwBQrXFaQIm", +"id": "3H7E05uiFuqgwBQrXFaQIm", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b27366ca114acb03e008d141f28b", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e0266ca114acb03e008d141f28b", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d0000485166ca114acb03e008d141f28b", +"width": 64 +} +], +"name": "U2M JR", +"popularity": 1, +"type": "artist", +"uri": "spotify:artist:3H7E05uiFuqgwBQrXFaQIm" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/6BMzJXRYmy28QVMZc09rGB" +}, +"followers": { +"href": null, +"total": 13 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/6BMzJXRYmy28QVMZc09rGB", +"id": "6BMzJXRYmy28QVMZc09rGB", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b273bd26433a01cf571413cbb1ec", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e02bd26433a01cf571413cbb1ec", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d00004851bd26433a01cf571413cbb1ec", +"width": 64 +} +], +"name": "U2oh", +"popularity": 0, +"type": "artist", +"uri": "spotify:artist:6BMzJXRYmy28QVMZc09rGB" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/4MtRKC7apgAyAd5uUjN3L4" +}, +"followers": { +"href": null, +"total": 64 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/4MtRKC7apgAyAd5uUjN3L4", +"id": "4MtRKC7apgAyAd5uUjN3L4", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b273b8ca9830e6849d80b41ef109", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e02b8ca9830e6849d80b41ef109", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d00004851b8ca9830e6849d80b41ef109", +"width": 64 +} +], +"name": "Zürcher Jugendblasorchester U25", +"popularity": 1, +"type": "artist", +"uri": "spotify:artist:4MtRKC7apgAyAd5uUjN3L4" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/18JD8DVlD1fakDAw7E9LFC" +}, +"followers": { +"href": null, +"total": 137412 +}, +"genres": [ +"bubblegum dance", +"eurodance", +"europop", +"hip house" +], +"href": "https://api.spotify.com/v1/artists/18JD8DVlD1fakDAw7E9LFC", +"id": "18JD8DVlD1fakDAw7E9LFC", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/c4fdb52d1be39038a8001116929044415fbd8962", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/54a2ea5b22f2966c5d30ba2aa5d5589adfe023ef", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/c11fdfd488dcf99e8b88975bba88205998ee7012", +"width": 160 +} +], +"name": "2 Unlimited", +"popularity": 59, +"type": "artist", +"uri": "spotify:artist:18JD8DVlD1fakDAw7E9LFC" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/0goZ9x7MGZF5rlaJOFrj1F" +}, +"followers": { +"href": null, +"total": 10 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/0goZ9x7MGZF5rlaJOFrj1F", +"id": "0goZ9x7MGZF5rlaJOFrj1F", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/195ebaebab44986c53d8423155299b47d16652db", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/00bc3410ab6f5065625f10d8a1c7a4c4f922e95e", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/c0c6bae7ea925c370fb91b2e27f4aa89182f8b3f", +"width": 160 +} +], +"name": "24U", +"popularity": 42, +"type": "artist", +"uri": "spotify:artist:0goZ9x7MGZF5rlaJOFrj1F" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/76pyFXpXITp0aRz4j3SyGJ" +}, +"followers": { +"href": null, +"total": 318 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/76pyFXpXITp0aRz4j3SyGJ", +"id": "76pyFXpXITp0aRz4j3SyGJ", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/9c72fe64128e7d01d8bae4275401e37a12562b43", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/d3a313ef8e07f8ae5bf4a1800690065a7d1001b8", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/3430c976f100fc5065b96fb588b3341d568c4f42", +"width": 160 +} +], +"name": "L2U", +"popularity": 21, +"type": "artist", +"uri": "spotify:artist:76pyFXpXITp0aRz4j3SyGJ" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/0j5kVHxvTgUN4nBIPKCLRJ" +}, +"followers": { +"href": null, +"total": 9504 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/0j5kVHxvTgUN4nBIPKCLRJ", +"id": "0j5kVHxvTgUN4nBIPKCLRJ", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/827f2b45917c1cc7bdc750a86b4f075c85fa615d", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/3a16f063bc027a66e29343156be2c206575c773b", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/f39027c69b58a49f13a07c778c215cdd592935b9", +"width": 160 +} +], +"name": "Never Get Used To People", +"popularity": 46, +"type": "artist", +"uri": "spotify:artist:0j5kVHxvTgUN4nBIPKCLRJ" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/1TxfUEM21kYVWinDMOqWwb" +}, +"followers": { +"href": null, +"total": 121 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/1TxfUEM21kYVWinDMOqWwb", +"id": "1TxfUEM21kYVWinDMOqWwb", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b27387b97641acd320159865afea", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e0287b97641acd320159865afea", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d0000485187b97641acd320159865afea", +"width": 64 +} +], +"name": "2f U-Flow", +"popularity": 29, +"type": "artist", +"uri": "spotify:artist:1TxfUEM21kYVWinDMOqWwb" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/0iwKcRbay1SnKY1IH8MNL8" +}, +"followers": { +"href": null, +"total": 2 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/0iwKcRbay1SnKY1IH8MNL8", +"id": "0iwKcRbay1SnKY1IH8MNL8", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b2739664d2726b29a5e642003027", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e029664d2726b29a5e642003027", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d000048519664d2726b29a5e642003027", +"width": 64 +} +], +"name": "y27uri", +"popularity": 30, +"type": "artist", +"uri": "spotify:artist:0iwKcRbay1SnKY1IH8MNL8" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/2wTNs9AmIOv5Fjs66HK1tV" +}, +"followers": { +"href": null, +"total": 15791 +}, +"genres": [ +"rhythm game" +], +"href": "https://api.spotify.com/v1/artists/2wTNs9AmIOv5Fjs66HK1tV", +"id": "2wTNs9AmIOv5Fjs66HK1tV", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/d4d7e6f174ee5be4c1099ccbe61220fcae904953", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/57a8c4bf2c20aece32d765ce9fc69330dd3cd18f", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/1d9e1c6d0aa5f080dbca4fc7d2b5457b5d5d8011", +"width": 160 +} +], +"name": "M2U", +"popularity": 41, +"type": "artist", +"uri": "spotify:artist:2wTNs9AmIOv5Fjs66HK1tV" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/1oUiDfTNWZCprR1GeRPs0i" +}, +"followers": { +"href": null, +"total": 15485 +}, +"genres": [ +"j-pixie", +"japanese math rock" +], +"href": "https://api.spotify.com/v1/artists/1oUiDfTNWZCprR1GeRPs0i", +"id": "1oUiDfTNWZCprR1GeRPs0i", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/5d40c50ce833008a578fa0c7d92fc65d0f222c54", +"width": 640 +}, +{ +"height": 320, +"url": "https://i.scdn.co/image/0b346e14627b90cd25e9020443122bc32681baed", +"width": 320 +}, +{ +"height": 160, +"url": "https://i.scdn.co/image/f80ef7a4ec092e5bf054bc245b014963561639e5", +"width": 160 +} +], +"name": "Lie and a Chameleon", +"popularity": 41, +"type": "artist", +"uri": "spotify:artist:1oUiDfTNWZCprR1GeRPs0i" +}, +{ +"external_urls": { +"spotify": "https://open.spotify.com/artist/6diA719p2OaW6zQnXCbRO9" +}, +"followers": { +"href": null, +"total": 236 +}, +"genres": [], +"href": "https://api.spotify.com/v1/artists/6diA719p2OaW6zQnXCbRO9", +"id": "6diA719p2OaW6zQnXCbRO9", +"images": [ +{ +"height": 640, +"url": "https://i.scdn.co/image/ab67616d0000b2737934fbc7e0876496ee772792", +"width": 640 +}, +{ +"height": 300, +"url": "https://i.scdn.co/image/ab67616d00001e027934fbc7e0876496ee772792", +"width": 300 +}, +{ +"height": 64, +"url": "https://i.scdn.co/image/ab67616d000048517934fbc7e0876496ee772792", +"width": 64 +} +], +"name": "US Two", +"popularity": 32, +"type": "artist", +"uri": "spotify:artist:6diA719p2OaW6zQnXCbRO9" +} +], +"limit": 20, +"next": "https://api.spotify.com/v1/search?query=U2&type=artist&offset=20&limit=20", +"offset": 0, +"previous": null, +"total": 922 +} +} diff --git a/tests/fixtures/symlink b/tests/fixtures/symlink new file mode 120000 index 0000000..64233a9 --- /dev/null +++ b/tests/fixtures/symlink @@ -0,0 +1 @@ +index.html \ No newline at end of file diff --git a/tests/fixtures/symlink2dir b/tests/fixtures/symlink2dir new file mode 120000 index 0000000..d7b73dc --- /dev/null +++ b/tests/fixtures/symlink2dir @@ -0,0 +1 @@ +empty_folder \ No newline at end of file diff --git a/tests/fixtures/test.aiff b/tests/fixtures/test.aiff new file mode 100644 index 0000000..1435115 Binary files /dev/null and b/tests/fixtures/test.aiff differ diff --git a/tests/fixtures/test.flac b/tests/fixtures/test.flac new file mode 100644 index 0000000..6c1270f Binary files /dev/null and b/tests/fixtures/test.flac differ diff --git a/tests/fixtures/test.lrc b/tests/fixtures/test.lrc new file mode 100644 index 0000000..d1730df --- /dev/null +++ b/tests/fixtures/test.lrc @@ -0,0 +1,6 @@ +[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 new file mode 100644 index 0000000..c469dd9 Binary files /dev/null and b/tests/fixtures/test.m4a differ diff --git a/tests/fixtures/test.mp3 b/tests/fixtures/test.mp3 new file mode 100644 index 0000000..7333249 Binary files /dev/null and b/tests/fixtures/test.mp3 differ diff --git a/tests/fixtures/test.ogg b/tests/fixtures/test.ogg new file mode 100644 index 0000000..507da90 Binary files /dev/null and b/tests/fixtures/test.ogg differ diff --git a/tests/fixtures/test.tak b/tests/fixtures/test.tak new file mode 100644 index 0000000..3f64080 Binary files /dev/null and b/tests/fixtures/test.tak differ diff --git a/tests/fixtures/test.txt b/tests/fixtures/test.txt new file mode 100644 index 0000000..c5a9c85 --- /dev/null +++ b/tests/fixtures/test.txt @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..155d88b Binary files /dev/null and b/tests/fixtures/test.wav differ diff --git a/tests/fixtures/test.wma b/tests/fixtures/test.wma new file mode 100644 index 0000000..2edb726 Binary files /dev/null and b/tests/fixtures/test.wma differ diff --git a/tests/fixtures/test.wv b/tests/fixtures/test.wv new file mode 100644 index 0000000..3722d28 Binary files /dev/null and b/tests/fixtures/test.wv differ diff --git a/tests/fixtures/zero_replaygain.mp3 b/tests/fixtures/zero_replaygain.mp3 new file mode 100644 index 0000000..96e6d21 Binary files /dev/null and b/tests/fixtures/zero_replaygain.mp3 differ diff --git a/tests/init_tests.go b/tests/init_tests.go new file mode 100644 index 0000000..582ad95 --- /dev/null +++ b/tests/init_tests.go @@ -0,0 +1,33 @@ +package tests + +import ( + "os" + "path/filepath" + "runtime" + "sync" + "testing" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" +) + +var once sync.Once + +func Init(t *testing.T, skipOnShort bool) { + if skipOnShort && testing.Short() { + t.Skip("skipping test in short mode.") + } + once.Do(func() { + _, file, _, _ := runtime.Caller(0) + appPath, _ := filepath.Abs(filepath.Join(filepath.Dir(file), "..")) + confPath, _ := filepath.Abs(filepath.Join(appPath, "tests", "navidrome-test.toml")) + println("Loading test configuration file from " + confPath) + _ = os.Chdir(appPath) + conf.LoadFromFile(confPath) + + noLog := os.Getenv("NOLOG") + if noLog != "" { + log.SetLevel(log.LevelError) + } + }) +} diff --git a/tests/mock_album_repo.go b/tests/mock_album_repo.go new file mode 100644 index 0000000..642ce6b --- /dev/null +++ b/tests/mock_album_repo.go @@ -0,0 +1,161 @@ +package tests + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +func CreateMockAlbumRepo() *MockAlbumRepo { + return &MockAlbumRepo{ + Data: make(map[string]*model.Album), + } +} + +type MockAlbumRepo struct { + model.AlbumRepository + Data map[string]*model.Album + All model.Albums + Err bool + Options model.QueryOptions + ReassignAnnotationCalls map[string]string // prevID -> newID +} + +func (m *MockAlbumRepo) SetError(err bool) { + 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] + } +} + +func (m *MockAlbumRepo) Exists(id string) (bool, error) { + if m.Err { + return false, errors.New("unexpected error") + } + _, found := m.Data[id] + return found, nil +} + +func (m *MockAlbumRepo) Get(id string) (*model.Album, error) { + 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 *MockAlbumRepo) Put(al *model.Album) error { + if m.Err { + return errors.New("unexpected error") + } + if al.ID == "" { + al.ID = id.NewRandom() + } + m.Data[al.ID] = al + return nil +} + +func (m *MockAlbumRepo) GetAll(qo ...model.QueryOptions) (model.Albums, error) { + if len(qo) > 0 { + m.Options = qo[0] + } + if m.Err { + return nil, errors.New("unexpected error") + } + return m.All, nil +} + +func (m *MockAlbumRepo) IncPlayCount(id string, timestamp time.Time) error { + if m.Err { + return errors.New("unexpected error") + } + if d, ok := m.Data[id]; ok { + d.PlayCount++ + d.PlayDate = ×tamp + return nil + } + return model.ErrNotFound +} +func (m *MockAlbumRepo) CountAll(...model.QueryOptions) (int64, error) { + return int64(len(m.All)), nil +} + +func (m *MockAlbumRepo) GetTouchedAlbums(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(album *model.Album) error { + if m.Err { + return errors.New("unexpected error") + } + return nil +} + +func (m *MockAlbumRepo) Search(q string, offset int, size int, options ...model.QueryOptions) (model.Albums, error) { + 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(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 +} + +// SetRating sets the rating for an album +func (m *MockAlbumRepo) SetRating(rating int, itemID string) error { + if m.Err { + return errors.New("unexpected error") + } + return nil +} + +// SetStar sets the starred status for albums +func (m *MockAlbumRepo) SetStar(starred bool, itemIDs ...string) error { + if m.Err { + return errors.New("unexpected error") + } + return nil +} + +var _ model.AlbumRepository = (*MockAlbumRepo)(nil) diff --git a/tests/mock_artist_repo.go b/tests/mock_artist_repo.go new file mode 100644 index 0000000..6d4792f --- /dev/null +++ b/tests/mock_artist_repo.go @@ -0,0 +1,160 @@ +package tests + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" +) + +func CreateMockArtistRepo() *MockArtistRepo { + return &MockArtistRepo{ + Data: make(map[string]*model.Artist), + } +} + +type MockArtistRepo struct { + model.ArtistRepository + Data map[string]*model.Artist + Err bool + Options model.QueryOptions +} + +func (m *MockArtistRepo) SetError(err bool) { + m.Err = err +} + +func (m *MockArtistRepo) SetData(artists model.Artists) { + m.Data = make(map[string]*model.Artist) + for i, a := range artists { + m.Data[a.ID] = &artists[i] + } +} + +func (m *MockArtistRepo) Exists(id string) (bool, error) { + if m.Err { + return false, errors.New("Error!") + } + _, found := m.Data[id] + return found, nil +} + +func (m *MockArtistRepo) Get(id string) (*model.Artist, error) { + if m.Err { + return nil, errors.New("Error!") + } + if d, ok := m.Data[id]; ok { + return d, nil + } + return nil, model.ErrNotFound +} + +func (m *MockArtistRepo) Put(ar *model.Artist, columsToUpdate ...string) error { + if m.Err { + return errors.New("error") + } + if ar.ID == "" { + ar.ID = id.NewRandom() + } + m.Data[ar.ID] = ar + return nil +} + +func (m *MockArtistRepo) IncPlayCount(id string, timestamp time.Time) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[id]; ok { + d.PlayCount++ + d.PlayDate = ×tamp + return nil + } + return model.ErrNotFound +} + +func (m *MockArtistRepo) GetAll(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) UpdateExternalInfo(artist *model.Artist) error { + if m.Err { + return errors.New("mock repo error") + } + return nil +} + +func (m *MockArtistRepo) RefreshStats(allArtists bool) (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + +func (m *MockArtistRepo) RefreshPlayCounts() (int64, error) { + if m.Err { + return 0, errors.New("mock repo error") + } + return int64(len(m.Data)), nil +} + +func (m *MockArtistRepo) GetIndex(includeMissing bool, libraryIds []int, roles ...model.Role) (model.ArtistIndexes, error) { + if m.Err { + return nil, errors.New("mock repo error") + } + + artists, err := m.GetAll() + 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) Search(q string, offset int, size int, 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 + allArtists, err := m.GetAll() + return allArtists, err +} + +var _ model.ArtistRepository = (*MockArtistRepo)(nil) diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go new file mode 100644 index 0000000..4aca8e9 --- /dev/null +++ b/tests/mock_data_store.go @@ -0,0 +1,287 @@ +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 + scrobbleBufferMu sync.Mutex + repoMu sync.Mutex + + // GC tracking + GCCalled bool + GCError error +} + +func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository { + if db.MockedLibrary == nil { + if db.RealDS != nil { + db.MockedLibrary = db.RealDS.Library(ctx) + } else { + db.MockedLibrary = &MockLibraryRepo{} + } + } + return db.MockedLibrary +} + +func (db *MockDataStore) Folder(ctx context.Context) model.FolderRepository { + if db.MockedFolder == nil { + if db.RealDS != nil { + db.MockedFolder = db.RealDS.Folder(ctx) + } else { + db.MockedFolder = struct{ model.FolderRepository }{} + } + } + return db.MockedFolder +} + +func (db *MockDataStore) Tag(ctx context.Context) model.TagRepository { + if db.MockedTag == nil { + if db.RealDS != nil { + db.MockedTag = db.RealDS.Tag(ctx) + } else { + db.MockedTag = struct{ model.TagRepository }{} + } + } + return db.MockedTag +} + +func (db *MockDataStore) Album(ctx context.Context) model.AlbumRepository { + if db.MockedAlbum == nil { + if db.RealDS != nil { + db.MockedAlbum = db.RealDS.Album(ctx) + } else { + db.MockedAlbum = CreateMockAlbumRepo() + } + } + return db.MockedAlbum +} + +func (db *MockDataStore) Artist(ctx context.Context) model.ArtistRepository { + if db.MockedArtist == nil { + if db.RealDS != nil { + db.MockedArtist = db.RealDS.Artist(ctx) + } else { + db.MockedArtist = CreateMockArtistRepo() + } + } + return db.MockedArtist +} + +func (db *MockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository { + db.repoMu.Lock() + defer db.repoMu.Unlock() + if db.MockedMediaFile == nil { + if db.RealDS != nil { + db.MockedMediaFile = db.RealDS.MediaFile(ctx) + } else { + db.MockedMediaFile = CreateMockMediaFileRepo() + } + } + return db.MockedMediaFile +} + +func (db *MockDataStore) Genre(ctx context.Context) model.GenreRepository { + if db.MockedGenre == nil { + if db.RealDS != nil { + db.MockedGenre = db.RealDS.Genre(ctx) + } else { + db.MockedGenre = &MockedGenreRepo{} + } + } + return db.MockedGenre +} + +func (db *MockDataStore) Playlist(ctx context.Context) model.PlaylistRepository { + if db.MockedPlaylist == nil { + if db.RealDS != nil { + db.MockedPlaylist = db.RealDS.Playlist(ctx) + } else { + db.MockedPlaylist = &MockPlaylistRepo{} + } + } + return db.MockedPlaylist +} + +func (db *MockDataStore) PlayQueue(ctx context.Context) model.PlayQueueRepository { + if db.MockedPlayQueue == nil { + if db.RealDS != nil { + db.MockedPlayQueue = db.RealDS.PlayQueue(ctx) + } else { + db.MockedPlayQueue = &MockPlayQueueRepo{} + } + } + return db.MockedPlayQueue +} + +func (db *MockDataStore) UserProps(ctx context.Context) model.UserPropsRepository { + if db.MockedUserProps == nil { + if db.RealDS != nil { + db.MockedUserProps = db.RealDS.UserProps(ctx) + } else { + db.MockedUserProps = &MockedUserPropsRepo{} + } + } + return db.MockedUserProps +} + +func (db *MockDataStore) Property(ctx context.Context) model.PropertyRepository { + if db.MockedProperty == nil { + if db.RealDS != nil { + db.MockedProperty = db.RealDS.Property(ctx) + } else { + db.MockedProperty = &MockedPropertyRepo{} + } + } + return db.MockedProperty +} + +func (db *MockDataStore) Share(ctx context.Context) model.ShareRepository { + if db.MockedShare == nil { + if db.RealDS != nil { + db.MockedShare = db.RealDS.Share(ctx) + } else { + db.MockedShare = &MockShareRepo{} + } + } + return db.MockedShare +} + +func (db *MockDataStore) User(ctx context.Context) model.UserRepository { + if db.MockedUser == nil { + if db.RealDS != nil { + db.MockedUser = db.RealDS.User(ctx) + } else { + db.MockedUser = CreateMockUserRepo() + } + } + return db.MockedUser +} + +func (db *MockDataStore) Transcoding(ctx context.Context) model.TranscodingRepository { + if db.MockedTranscoding == nil { + if db.RealDS != nil { + db.MockedTranscoding = db.RealDS.Transcoding(ctx) + } else { + db.MockedTranscoding = struct{ model.TranscodingRepository }{} + } + } + return db.MockedTranscoding +} + +func (db *MockDataStore) Player(ctx context.Context) model.PlayerRepository { + if db.MockedPlayer == nil { + if db.RealDS != nil { + db.MockedPlayer = db.RealDS.Player(ctx) + } else { + db.MockedPlayer = struct{ model.PlayerRepository }{} + } + } + return db.MockedPlayer +} + +func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepository { + db.scrobbleBufferMu.Lock() + defer db.scrobbleBufferMu.Unlock() + if db.MockedScrobbleBuffer == nil { + if db.RealDS != nil { + db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx) + } else { + db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{} + } + } + return db.MockedScrobbleBuffer +} + +func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository { + if db.MockedScrobble == nil { + if db.RealDS != nil { + db.MockedScrobble = db.RealDS.Scrobble(ctx) + } else { + db.MockedScrobble = &MockScrobbleRepo{ctx: ctx} + } + } + return db.MockedScrobble +} + +func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository { + if db.MockedRadio == nil { + if db.RealDS != nil { + db.MockedRadio = db.RealDS.Radio(ctx) + } else { + db.MockedRadio = CreateMockedRadioRepo() + } + } + return db.MockedRadio +} + +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) Resource(ctx context.Context, m any) model.ResourceRepository { + switch m.(type) { + case model.MediaFile, *model.MediaFile: + return db.MediaFile(ctx).(model.ResourceRepository) + case model.Album, *model.Album: + return db.Album(ctx).(model.ResourceRepository) + case model.Artist, *model.Artist: + return db.Artist(ctx).(model.ResourceRepository) + case model.User, *model.User: + return db.User(ctx).(model.ResourceRepository) + case model.Playlist, *model.Playlist: + return db.Playlist(ctx).(model.ResourceRepository) + case model.Radio, *model.Radio: + return db.Radio(ctx).(model.ResourceRepository) + case model.Share, *model.Share: + return db.Share(ctx).(model.ResourceRepository) + case model.Genre, *model.Genre: + return db.Genre(ctx).(model.ResourceRepository) + case model.Tag, *model.Tag: + return db.Tag(ctx).(model.ResourceRepository) + case model.Transcoding, *model.Transcoding: + return db.Transcoding(ctx).(model.ResourceRepository) + case model.Player, *model.Player: + return db.Player(ctx).(model.ResourceRepository) + default: + return struct{ model.ResourceRepository }{} + } +} + +func (db *MockDataStore) GC(context.Context, ...int) error { + db.GCCalled = true + if db.GCError != nil { + return db.GCError + } + return nil +} + +func (db *MockDataStore) ReindexAll(context.Context) error { + return nil +} diff --git a/tests/mock_ffmpeg.go b/tests/mock_ffmpeg.go new file mode 100644 index 0000000..a792ae9 --- /dev/null +++ b/tests/mock_ffmpeg.go @@ -0,0 +1,70 @@ +package tests + +import ( + "context" + "io" + "strings" + "sync" + "sync/atomic" +) + +func NewMockFFmpeg(data string) *MockFFmpeg { + return &MockFFmpeg{Reader: strings.NewReader(data)} +} + +type MockFFmpeg struct { + io.Reader + lock sync.Mutex + closed atomic.Bool + Error error +} + +func (ff *MockFFmpeg) IsAvailable() bool { + return true +} + +func (ff *MockFFmpeg) Transcode(context.Context, string, string, int, int) (io.ReadCloser, error) { + if ff.Error != nil { + return nil, ff.Error + } + return ff, nil +} + +func (ff *MockFFmpeg) ExtractImage(context.Context, string) (io.ReadCloser, error) { + if ff.Error != nil { + return nil, ff.Error + } + return ff, nil +} + +func (ff *MockFFmpeg) Probe(context.Context, []string) (string, error) { + if ff.Error != nil { + return "", ff.Error + } + return "", nil +} +func (ff *MockFFmpeg) CmdPath() (string, error) { + if ff.Error != nil { + return "", ff.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() + return ff.Reader.Read(p) +} + +func (ff *MockFFmpeg) Close() error { + ff.closed.Store(true) + return nil +} + +func (ff *MockFFmpeg) IsClosed() bool { + return ff.closed.Load() +} diff --git a/tests/mock_genre_repo.go b/tests/mock_genre_repo.go new file mode 100644 index 0000000..122ccc2 --- /dev/null +++ b/tests/mock_genre_repo.go @@ -0,0 +1,38 @@ +package tests + +import ( + "github.com/navidrome/navidrome/model" +) + +type MockedGenreRepo struct { + Error error + Data map[string]model.Genre +} + +func (r *MockedGenreRepo) init() { + if r.Data == nil { + r.Data = make(map[string]model.Genre) + } +} + +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 { + all = append(all, g) + } + return all, nil +} + +func (r *MockedGenreRepo) Put(g *model.Genre) error { + if r.Error != nil { + return r.Error + } + r.init() + r.Data[g.ID] = *g + return nil +} diff --git a/tests/mock_library_repo.go b/tests/mock_library_repo.go new file mode 100644 index 0000000..4d7539a --- /dev/null +++ b/tests/mock_library_repo.go @@ -0,0 +1,312 @@ +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 +} + +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(...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(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(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(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(library *model.Library) error { + 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(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() error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) AddArtist(id int, artistID string) error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) ScanBegin(id int, fullScan bool) error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) ScanEnd(id int) error { + if m.Err != nil { + return m.Err + } + return nil +} + +func (m *MockLibraryRepo) ScanInProgress() (bool, error) { + if m.Err != nil { + return false, m.Err + } + return false, nil +} + +func (m *MockLibraryRepo) RefreshStats(id int) error { + return nil +} + +// User-library association methods - mock implementations + +func (m *MockLibraryRepo) GetUsersWithLibraryAccess(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(options ...rest.QueryOptions) (int64, error) { + return m.CountAll() +} + +func (m *MockLibraryRepo) Read(id string) (interface{}, error) { + idInt, _ := strconv.Atoi(id) + mf, err := m.Get(idInt) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return mf, err +} + +func (m *MockLibraryRepo) ReadAll(options ...rest.QueryOptions) (interface{}, error) { + return m.GetAll() +} + +func (m *MockLibraryRepo) EntityName() string { + return "library" +} + +func (m *MockLibraryRepo) NewInstance() interface{} { + return &model.Library{} +} + +// REST Repository methods (string-based IDs) + +func (m *MockLibraryRepo) Save(entity interface{}) (string, error) { + lib := entity.(*model.Library) + 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(id string, entity interface{}, cols ...string) error { + lib := entity.(*model.Library) + 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 _ model.ResourceRepository = (*MockLibraryRepo)(nil) diff --git a/tests/mock_mediafile_repo.go b/tests/mock_mediafile_repo.go new file mode 100644 index 0000000..5b38a71 --- /dev/null +++ b/tests/mock_mediafile_repo.go @@ -0,0 +1,299 @@ +package tests + +import ( + "cmp" + "errors" + "maps" + "slices" + "time" + + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/slice" +) + +func CreateMockMediaFileRepo() *MockMediaFileRepo { + return &MockMediaFileRepo{ + 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 + CountAllValue int64 + CountAllOptions model.QueryOptions + DeleteAllMissingValue int64 + 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) +} + +func (m *MockMediaFileRepo) SetError(err bool) { + m.Err = err +} + +func (m *MockMediaFileRepo) SetData(mfs model.MediaFiles) { + m.Data = make(map[string]*model.MediaFile) + for i, mf := range mfs { + m.Data[mf.ID] = &mfs[i] + } +} + +func (m *MockMediaFileRepo) Exists(id string) (bool, error) { + if m.Err { + return false, errors.New("error") + } + _, found := m.Data[id] + return found, nil +} + +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) GetWithParticipants(id string) (*model.MediaFile, error) { + if m.Err { + return nil, errors.New("error") + } + if d, ok := m.Data[id]; ok { + return d, nil + } + return nil, model.ErrNotFound +} + +func (m *MockMediaFileRepo) GetAll(qo ...model.QueryOptions) (model.MediaFiles, error) { + if len(qo) > 0 { + m.Options = qo[0] + } + 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 { + 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 +} + +func (m *MockMediaFileRepo) Put(mf *model.MediaFile) error { + if m.Err { + return errors.New("error") + } + if mf.ID == "" { + mf.ID = id.NewRandom() + } + m.Data[mf.ID] = mf + return nil +} + +func (m *MockMediaFileRepo) Delete(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) IncPlayCount(id string, timestamp time.Time) error { + if m.Err { + return errors.New("error") + } + if d, ok := m.Data[id]; ok { + d.PlayCount++ + d.PlayDate = ×tamp + return nil + } + return model.ErrNotFound +} + +func (m *MockMediaFileRepo) FindByAlbum(artistId string) (model.MediaFiles, error) { + if m.Err { + return nil, errors.New("error") + } + var res = make(model.MediaFiles, len(m.Data)) + i := 0 + for _, a := range m.Data { + if a.AlbumID == artistId { + res[i] = *a + i++ + } + } + + return res, nil +} + +func (m *MockMediaFileRepo) GetMissingAndMatching(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(opts ...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("error") + } + if m.CountAllValue != 0 { + if len(opts) > 0 { + m.CountAllOptions = opts[0] + } + return m.CountAllValue, nil + } + return int64(len(m.Data)), nil +} + +func (m *MockMediaFileRepo) DeleteAllMissing() (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 +} + +// ResourceRepository methods +func (m *MockMediaFileRepo) Count(...rest.QueryOptions) (int64, error) { + return m.CountAll() +} + +func (m *MockMediaFileRepo) Read(id string) (interface{}, error) { + mf, err := m.Get(id) + if errors.Is(err, model.ErrNotFound) { + return nil, rest.ErrNotFound + } + return mf, err +} + +func (m *MockMediaFileRepo) ReadAll(...rest.QueryOptions) (interface{}, error) { + return m.GetAll() +} + +func (m *MockMediaFileRepo) EntityName() string { + return "mediafile" +} + +func (m *MockMediaFileRepo) NewInstance() interface{} { + return &model.MediaFile{} +} + +func (m *MockMediaFileRepo) Search(q string, offset int, size int, 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 + allFiles, err := m.GetAll() + return allFiles, err +} + +// Cross-library move detection mock methods +func (m *MockMediaFileRepo) FindRecentFilesByMBZTrackID(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(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 +} + +var _ model.MediaFileRepository = (*MockMediaFileRepo)(nil) +var _ model.ResourceRepository = (*MockMediaFileRepo)(nil) diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go new file mode 100644 index 0000000..60dc98b --- /dev/null +++ b/tests/mock_playlist_repo.go @@ -0,0 +1,33 @@ +package tests + +import ( + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" +) + +type MockPlaylistRepo struct { + model.PlaylistRepository + + Entity *model.Playlist + Error error +} + +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) Count(_ ...rest.QueryOptions) (int64, error) { + if m.Error != nil { + return 0, m.Error + } + if m.Entity == nil { + return 0, nil + } + return 1, nil +} diff --git a/tests/mock_playqueue_repo.go b/tests/mock_playqueue_repo.go new file mode 100644 index 0000000..19976db --- /dev/null +++ b/tests/mock_playqueue_repo.go @@ -0,0 +1,65 @@ +package tests + +import ( + "errors" + + "github.com/navidrome/navidrome/model" +) + +type MockPlayQueueRepo struct { + model.PlayQueueRepository + Queue *model.PlayQueue + Err bool + LastCols []string +} + +func (m *MockPlayQueueRepo) Store(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(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(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(userId string) error { + if m.Err { + return errors.New("error") + } + m.Queue = nil + return nil +} diff --git a/tests/mock_property_repo.go b/tests/mock_property_repo.go new file mode 100644 index 0000000..9adc66e --- /dev/null +++ b/tests/mock_property_repo.go @@ -0,0 +1,59 @@ +package tests + +import "github.com/navidrome/navidrome/model" + +type MockedPropertyRepo struct { + model.PropertyRepository + Error error + Data map[string]string +} + +func (p *MockedPropertyRepo) init() { + if p.Data == nil { + p.Data = make(map[string]string) + } +} + +func (p *MockedPropertyRepo) Put(id string, value string) error { + if p.Error != nil { + return p.Error + } + p.init() + p.Data[id] = value + return nil +} + +func (p *MockedPropertyRepo) Get(id string) (string, error) { + if p.Error != nil { + return "", p.Error + } + p.init() + if v, ok := p.Data[id]; ok { + return v, nil + } + return "", model.ErrNotFound +} + +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) + return nil + } + return model.ErrNotFound +} + +func (p *MockedPropertyRepo) DefaultGet(id string, defaultValue string) (string, error) { + if p.Error != nil { + return "", p.Error + } + p.init() + v, err := p.Get(id) + if err != nil { + return defaultValue, nil //nolint:nilerr + } + return v, nil +} diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go new file mode 100644 index 0000000..279b735 --- /dev/null +++ b/tests/mock_radio_repository.go @@ -0,0 +1,85 @@ +package tests + +import ( + "errors" + + "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 + Options model.QueryOptions +} + +func CreateMockedRadioRepo() *MockedRadioRepo { + return &MockedRadioRepo{} +} + +func (m *MockedRadioRepo) SetError(err bool) { + m.Err = err +} + +func (m *MockedRadioRepo) CountAll(options ...model.QueryOptions) (int64, error) { + if m.Err { + return 0, errors.New("error") + } + return int64(len(m.Data)), nil +} + +func (m *MockedRadioRepo) Delete(id string) error { + if m.Err { + return errors.New("Error!") + } + + _, found := m.Data[id] + + if !found { + return errors.New("not found") + } + + delete(m.Data, id) + return nil +} + +func (m *MockedRadioRepo) Exists(id string) (bool, error) { + if m.Err { + return false, errors.New("Error!") + } + _, found := m.Data[id] + return found, nil +} + +func (m *MockedRadioRepo) Get(id string) (*model.Radio, error) { + if m.Err { + return nil, errors.New("Error!") + } + if d, ok := m.Data[id]; ok { + return d, nil + } + return nil, model.ErrNotFound +} + +func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) { + if len(qo) > 0 { + m.Options = qo[0] + } + if m.Err { + return nil, errors.New("Error!") + } + return m.All, nil +} + +func (m *MockedRadioRepo) Put(radio *model.Radio) error { + if m.Err { + return errors.New("error") + } + if radio.ID == "" { + radio.ID = id.NewRandom() + } + m.Data[radio.ID] = radio + return nil +} diff --git a/tests/mock_scanner.go b/tests/mock_scanner.go new file mode 100644 index 0000000..5239672 --- /dev/null +++ b/tests/mock_scanner.go @@ -0,0 +1,120 @@ +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 +} + +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}) + + 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, + }) + + 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 +} diff --git a/tests/mock_scrobble_buffer_repo.go b/tests/mock_scrobble_buffer_repo.go new file mode 100644 index 0000000..5865f42 --- /dev/null +++ b/tests/mock_scrobble_buffer_repo.go @@ -0,0 +1,93 @@ +package tests + +import ( + "sync" + "time" + + "github.com/navidrome/navidrome/model" +) + +type MockedScrobbleBufferRepo struct { + Error error + Data model.ScrobbleEntries + mu sync.RWMutex +} + +func CreateMockedScrobbleBufferRepo() *MockedScrobbleBufferRepo { + return &MockedScrobbleBufferRepo{} +} + +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 { + if e.Service == service { + userIds[e.UserID] = struct{}{} + } + } + var result []string + for uid := range userIds { + result = append(result, uid) + } + return result, nil +} + +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{ + MediaFile: model.MediaFile{ID: mediaFileId}, + Service: service, + UserID: userId, + PlayTime: playTime, + EnqueueTime: time.Now(), + }) + return nil +} + +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 { + if e.Service == service && e.UserID == userId { + return &e, nil + } + } + return nil, nil +} + +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 { + 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 + return nil +} + +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 +} diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go new file mode 100644 index 0000000..34561c2 --- /dev/null +++ b/tests/mock_scrobble_repo.go @@ -0,0 +1,24 @@ +package tests + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" +) + +type MockScrobbleRepo struct { + RecordedScrobbles []model.Scrobble + ctx context.Context +} + +func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error { + user, _ := request.UserFrom(m.ctx) + m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{ + MediaFileID: fileID, + UserID: user.ID, + SubmissionTime: submissionTime, + }) + return nil +} diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go new file mode 100644 index 0000000..ef026ca --- /dev/null +++ b/tests/mock_share_repo.go @@ -0,0 +1,46 @@ +package tests + +import ( + "github.com/deluan/rest" + "github.com/navidrome/navidrome/model" +) + +type MockShareRepo struct { + model.ShareRepository + rest.Repository + rest.Persistable + + Entity interface{} + ID string + Cols []string + Error 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" + } + m.Entity = s + return s.ID, nil +} + +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.Cols = cols + return nil +} + +func (m *MockShareRepo) Exists(id string) (bool, error) { + if m.Error != nil { + return false, m.Error + } + return id == m.ID, nil +} diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go new file mode 100644 index 0000000..12db0d7 --- /dev/null +++ b/tests/mock_transcoding_repo.go @@ -0,0 +1,24 @@ +package tests + +import "github.com/navidrome/navidrome/model" + +type MockTranscodingRepo struct { + model.TranscodingRepository +} + +func (m *MockTranscodingRepo) Get(id string) (*model.Transcoding, error) { + return &model.Transcoding{ID: id, TargetFormat: "mp3", DefaultBitRate: 160}, nil +} + +func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, error) { + switch format { + case "mp3": + return &model.Transcoding{ID: "mp31", TargetFormat: "mp3", DefaultBitRate: 160}, nil + case "oga": + return &model.Transcoding{ID: "oga1", TargetFormat: "oga", DefaultBitRate: 128}, nil + case "opus": + return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil + default: + return nil, model.ErrNotFound + } +} diff --git a/tests/mock_user_props_repo.go b/tests/mock_user_props_repo.go new file mode 100644 index 0000000..1b1e176 --- /dev/null +++ b/tests/mock_user_props_repo.go @@ -0,0 +1,59 @@ +package tests + +import "github.com/navidrome/navidrome/model" + +type MockedUserPropsRepo struct { + model.UserPropsRepository + Error error + Data map[string]string +} + +func (p *MockedUserPropsRepo) init() { + if p.Data == nil { + p.Data = make(map[string]string) + } +} + +func (p *MockedUserPropsRepo) Put(userId, key string, value string) error { + if p.Error != nil { + return p.Error + } + p.init() + p.Data[userId+key] = value + return nil +} + +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 { + return v, nil + } + return "", model.ErrNotFound +} + +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) + return nil + } + return model.ErrNotFound +} + +func (p *MockedUserPropsRepo) DefaultGet(userId, key string, defaultValue string) (string, error) { + if p.Error != nil { + return "", p.Error + } + p.init() + v, err := p.Get(userId, key) + if err != nil { + return defaultValue, nil //nolint:nilerr + } + return v, nil +} diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go new file mode 100644 index 0000000..9f3dd67 --- /dev/null +++ b/tests/mock_user_repo.go @@ -0,0 +1,125 @@ +package tests + +import ( + "encoding/base64" + "fmt" + "strings" + "time" + + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils/gg" +) + +func CreateMockUserRepo() *MockedUserRepo { + return &MockedUserRepo{ + Data: map[string]*model.User{}, + UserLibraries: map[string][]int{}, + } +} + +type MockedUserRepo struct { + model.UserRepository + Error error + Data map[string]*model.User + UserLibraries map[string][]int // userID -> libraryIDs +} + +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(usr *model.User) error { + if u.Error != nil { + return u.Error + } + if usr.ID == "" { + usr.ID = base64.StdEncoding.EncodeToString([]byte(usr.UserName)) + } + usr.Password = usr.NewPassword + u.Data[strings.ToLower(usr.UserName)] = usr + return nil +} + +func (u *MockedUserRepo) FindByUsername(username string) (*model.User, error) { + if u.Error != nil { + return nil, u.Error + } + usr, ok := u.Data[strings.ToLower(username)] + if !ok { + return nil, model.ErrNotFound + } + return usr, nil +} + +func (u *MockedUserRepo) FindByUsernameWithPassword(username string) (*model.User, error) { + return u.FindByUsername(username) +} + +func (u *MockedUserRepo) Get(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) UpdateLastLoginAt(id string) error { + for _, usr := range u.Data { + if usr.ID == id { + usr.LastLoginAt = gg.P(time.Now()) + return nil + } + } + return u.Error +} + +func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { + for _, usr := range u.Data { + if usr.ID == id { + usr.LastAccessAt = gg.P(time.Now()) + return nil + } + } + return u.Error +} + +// Library association methods - mock implementations + +func (u *MockedUserRepo) GetUserLibraries(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(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 +} diff --git a/tests/test_helpers.go b/tests/test_helpers.go new file mode 100644 index 0000000..0a2cad4 --- /dev/null +++ b/tests/test_helpers.go @@ -0,0 +1,60 @@ +package tests + +import ( + "context" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/id" + "github.com/sirupsen/logrus" + "github.com/sirupsen/logrus/hooks/test" +) + +type testingT interface { + TempDir() string +} + +func TempFileName(t testingT, prefix, suffix string) string { + return filepath.Join(t.TempDir(), prefix+id.NewRandom()+suffix) +} + +func TempFile(t testingT, prefix, suffix string) (*os.File, string, error) { + name := TempFileName(t, prefix, suffix) + f, err := os.Create(name) + return f, name, err +} + +// 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/ui/.eslintignore b/ui/.eslintignore new file mode 100644 index 0000000..86c39ad --- /dev/null +++ b/ui/.eslintignore @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..bb17b13 --- /dev/null +++ b/ui/.eslintrc @@ -0,0 +1,61 @@ +{ + "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 new file mode 100644 index 0000000..3459a0e --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,7 @@ +node_modules +.eslintcache + +build/* +!build/.gitkeep +/coverage/ +public/3rdparty/workbox \ No newline at end of file diff --git a/ui/bin/update-workbox.sh b/ui/bin/update-workbox.sh new file mode 100755 index 0000000..f2282ca --- /dev/null +++ b/ui/bin/update-workbox.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env sh + +set -e + +export WORKBOX_DIR=public/3rdparty/workbox + +rm -rf ${WORKBOX_DIR} +workbox copyLibraries build/3rdparty/ + +mkdir -p ${WORKBOX_DIR} +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/build/.gitkeep b/ui/build/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/embed.go b/ui/embed.go new file mode 100644 index 0000000..3e2c413 --- /dev/null +++ b/ui/embed.go @@ -0,0 +1,14 @@ +package ui + +import ( + "embed" + "io/fs" +) + +//go:embed build/* +var filesystem embed.FS + +func BuildAssets() fs.FS { + build, _ := fs.Sub(filesystem, "build") + return build +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..0e60ec6 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + Navidrome + + + + + +
+ + + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..59f4cbe --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,11718 @@ +{ + "name": "ui", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ui", + "hasInstallScript": true, + "dependencies": { + "@material-ui/core": "^4.12.4", + "@material-ui/icons": "^4.11.3", + "@material-ui/lab": "^4.0.0-alpha.61", + "@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", + "history": "^4.10.1", + "inflection": "^3.0.2", + "jwt-decode": "^4.0.0", + "lodash.throttle": "^4.1.1", + "navidrome-music-player": "4.25.1", + "prop-types": "^15.8.1", + "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-dom": "^17.0.2", + "react-drag-listview": "^0.1.9", + "react-ga": "^3.3.1", + "react-hotkeys": "^2.0.0", + "react-icons": "^5.5.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" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@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", + "ra-test": "^3.19.12", + "typescript": "^5.8.3", + "vite": "^7.1.12", + "vite-plugin-pwa": "^1.1.0", + "vitest": "^4.0.3" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "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" + } + }, + "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 + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.2.tgz", + "integrity": "sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "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==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.1.tgz", + "integrity": "sha512-WnuuDILl9oOBbKnb4L+DyODx7iC47XfzmNCpTttFsSp6hTG7XZxu60+4IO+2/hPfcGOoKbFiwoI/+zwARbNQow==", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.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==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@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==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "regexpu-core": "^6.2.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@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==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", + "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": { + "is-core-module": "^2.16.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/@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==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "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==", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "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==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.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==", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "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==", + "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==", + "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==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", + "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", + "dependencies": { + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "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==", + "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==", + "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==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", + "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "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==", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "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==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@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==", + "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-async-generator-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.27.1.tgz", + "integrity": "sha512-eST9RrwlpaoJBDHShc+DS2SG4ATTi2MYNb4OxYkf3n+7eb49LWpnS+HSpVfW4x927qQwgk8A2hGNVaajAEw0EA==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^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-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "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-block-scoping": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.1.tgz", + "integrity": "sha512-QEcFlMl9nGTgh1rn2nIeU5bkfb9BAjaQcWbiP4LvKxUot52ABcTkpcyJ7f2Q2U2RuQ84BNLgts3jRme2dTx6Fw==", + "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-class-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", + "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.27.1.tgz", + "integrity": "sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.27.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.27.1.tgz", + "integrity": "sha512-ttDCqhfvpE9emVkXbPD8vyxxh4TWYACVybGkDj+oReOGwnp066ITEivDlLwe0b1R0+evJ13IXQuLNB5w1fhC5Q==", + "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-dotall-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@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-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==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "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==", + "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-exponentiation-operator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", + "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-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==", + "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-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==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", + "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-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==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", + "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-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==", + "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-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==", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^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-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==", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "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==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", + "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-numeric-separator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", + "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-object-rest-spread": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.27.2.tgz", + "integrity": "sha512-AIUHD7xJ1mCrj3uPozvtngY3s0xpv7Nu7DoUSnzNY6Xam1Cy4rUznR//pvMHOhQ4AvbCexhbqXCtpxGHOGOO6g==", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", + "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-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.1.tgz", + "integrity": "sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@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-property-in-object": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "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-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "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-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "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-regenerator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.1.tgz", + "integrity": "sha512-B19lbbL7PMrKr52BNPjCqg1IyNUIjTcxKj8uX9zHO+PmWN93s19NDr/f69mIkEp2x9nmDJ08a7lgHaTTzvW7mw==", + "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-regexp-modifiers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "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==", + "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-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==", + "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-spread": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "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-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==", + "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-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==", + "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-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==", + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@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-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==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@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-sets-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.27.2.tgz", + "integrity": "sha512-Ma4zSuYSlGNRlCLO+EAzLnCmJK2vdstgv+n7aUP+/IKZrOfWHOJVdSJtuub8RzHTj3ahD37k5OKJWvzf16TQyQ==", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@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.27.1", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.27.1", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.27.1", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.1", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@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.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.27.2", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.1", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.27.1", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@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.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.11.0", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.40.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "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==", + "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==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs3": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.27.1.tgz", + "integrity": "sha512-909rVuj3phpjW6y0MCXAZ5iNeORePa6ldJvp2baWGcTjwqbBDDz6xoS5JHJ7lS88NlwLYj07ImL/8IUMtDZzTA==", + "dev": true, + "dependencies": { + "core-js-pure": "^3.30.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.3.tgz", + "integrity": "sha512-XBG3talrhid44BY1x3MHzUx/aTG8+x/Zi57M4aTKK9RFB4aLlF3TTSzfzn8nWVHWL3FgAXAxmupmDd6VWww+pw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.9.tgz", + "integrity": "sha512-wILs5Zk7BU86UArYBJTPy/FMPPKVKHMj1ycCEyf3VUptol0JNRLFU/BZsJ4aiIHJEbSLiizzRrw8Pc1uAEDrXw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", + "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", + "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz", + "integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz", + "integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz", + "integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz", + "integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz", + "integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz", + "integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz", + "integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz", + "integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz", + "integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz", + "integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz", + "integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz", + "integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz", + "integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz", + "integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz", + "integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz", + "integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz", + "integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz", + "integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz", + "integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz", + "integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz", + "integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz", + "integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz", + "integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz", + "integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz", + "integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "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.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.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" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/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, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/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/@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, + "engines": { + "node": ">=10" + }, + "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==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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.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/@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, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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 + }, + "node_modules/@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "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==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "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==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "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==" + }, + "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==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "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.", + "dependencies": { + "@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" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/material-ui" + }, + "peerDependencies": { + "@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/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==", + "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==", + "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.", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.11.3", + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@material-ui/styles": { + "version": "4.11.5", + "resolved": "https://registry.npmjs.org/@material-ui/styles/-/styles-4.11.5.tgz", + "integrity": "sha512-o/41ot5JJiUsIETME9wVLAJrmIWL3j0R0Bj2kCOLbSfqEkKf0fmaPt+5vtblUh5eXr2S+J/8J3DaCb10+CzPGA==", + "deprecated": "Material UI v4 doesn't receive active development since September 2021. See the guide https://mui.com/material-ui/migration/migration-v4/ to upgrade to v5.", + "dependencies": { + "@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" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/material-ui" + }, + "peerDependencies": { + "@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/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==", + "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==", + "dependencies": { + "@babel/runtime": "^7.4.4", + "@material-ui/utils": "^4.11.3", + "csstype": "^2.5.2", + "prop-types": "^15.7.2" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/material-ui" + }, + "peerDependencies": { + "@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/types": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@material-ui/types/-/types-5.1.0.tgz", + "integrity": "sha512-7cqRjrY50b8QzRSYyhSpx4WRw2YuO0KKIGQEVk5J8uoz2BanawykgZGoWEqKm7pVIbzFDN0SpPcVV4IhOFkl8A==", + "peerDependencies": { + "@types/react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.4.4", + "prop-types": "^15.7.2", + "react-is": "^16.8.0 || ^17.0.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0", + "react-dom": "^16.8.0 || ^17.0.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, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "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==" + }, + "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==" + }, + "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==" + }, + "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==", + "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", + "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", + "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" + }, + "node_modules/@redux-saga/delay-p": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz", + "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==", + "license": "MIT", + "dependencies": { + "@redux-saga/symbols": "^1.2.1" + } + }, + "node_modules/@redux-saga/is": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz", + "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==", + "license": "MIT", + "dependencies": { + "@redux-saga/symbols": "^1.2.1", + "@redux-saga/types": "^1.3.1" + } + }, + "node_modules/@redux-saga/symbols": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz", + "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==", + "license": "MIT" + }, + "node_modules/@redux-saga/types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz", + "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", + "dev": 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==", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve/node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": { + "is-core-module": "^2.16.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/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "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 + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", + "integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==", + "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==" + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true + }, + "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==", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "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==", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "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==", + "dependencies": { + "defer-to-connect": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "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", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.0", + "@types/react-dom": "<18.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "<18.0.0", + "react-dom": "<18.0.0" + } + }, + "node_modules/@testing-library/react-hooks": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz", + "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==", + "dev": true, + "dependencies": { + "@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" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0", + "react-test-renderer": ">=16.9.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-test-renderer": { + "optional": true + } + } + }, + "node_modules/@testing-library/react/node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dev": true, + "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" + }, + "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, + "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, + "engines": { + "node": ">=12", + "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==", + "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, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@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, + "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, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "devOptional": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "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 + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==" + }, + "node_modules/@types/node": { + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "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==" + }, + "node_modules/@types/prop-types": { + "version": "15.7.14", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", + "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==" + }, + "node_modules/@types/react": { + "version": "17.0.89", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz", + "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "@types/scheduler": "^0.16", + "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==", + "dev": true, + "peerDependencies": { + "@types/react": "^17.0.0" + } + }, + "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==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "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==", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "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==" + }, + "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==" + }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true + }, + "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==" + }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "15.0.19", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", + "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "dev": true, + "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 + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "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", + "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" + }, + "engines": { + "node": "^16.0.0 || >=18.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" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "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" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "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" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "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" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.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==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.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==", + "dev": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz", + "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==", + "dev": true, + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.43", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.3.tgz", + "integrity": "sha512-I+MlLwyJRBjmJr1kFYSxoseINbIdpxIAeK10jmXgB0FUtIfdYsvM3lGAvBu5yk8WPyhefzdmbCHCc1idFbNRcg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.3", + "ast-v8-to-istanbul": "^0.3.5", + "debug": "^4.4.3", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.2.0", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.3", + "vitest": "4.0.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.3.tgz", + "integrity": "sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "chai": "^6.0.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.3.tgz", + "integrity": "sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.19" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz", + "integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.3.tgz", + "integrity": "sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.0.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.3.tgz", + "integrity": "sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.3", + "magic-string": "^0.30.19", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.3.tgz", + "integrity": "sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz", + "integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.0.3", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "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, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "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", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "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==", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "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==", + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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==", + "dependencies": { + "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==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "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/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "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==", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "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, + "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==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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, + "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==", + "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": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "engines": { + "node": ">=0.10.0" + } + }, + "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, + "engines": { + "node": ">=12" + } + }, + "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 + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz", + "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "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==" + }, + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "engines": { + "node": ">= 4.0.0" + } + }, + "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==", + "engines": { + "node": ">=4" + } + }, + "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==", + "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==", + "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.10.3", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", + "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", + "dev": true, + "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==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", + "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.4", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.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==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", + "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3", + "core-js-compat": "^3.40.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", + "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.4" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "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==", + "dependencies": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "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==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "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==", + "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==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "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==" + }, + "node_modules/boxen": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-4.2.0.tgz", + "integrity": "sha512-eB4uT9RGzg2odpER62bBwSLvUeGC+WbRjjyyFhGsKnc8wp/m0+hQsMUvUe3H2V0D5vw0nBdO1hCJoZo5mKeuIQ==", + "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/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/boxen/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "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==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", + "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001716", + "electron-to-chromium": "^1.5.149", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "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" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "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==" + }, + "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==", + "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==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==" + }, + "node_modules/cacheable-request/node_modules/keyv": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dependencies": { + "json-buffer": "3.0.0" + } + }, + "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==", + "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==", + "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==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "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==", + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001718", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", + "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/chai": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.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" + }, + "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==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "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==" + }, + "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==" + }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "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==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "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==" + }, + "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==", + "engines": { + "node": ">=4.0.0" + } + }, + "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==" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dependencies": { + "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": ">=8" + } + }, + "node_modules/configstore/node_modules/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==", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/configstore/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "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==", + "dependencies": { + "lodash.isequalwith": "^4.4.0", + "prop-types": "^15.7.2" + }, + "optionalDependencies": { + "immutable": "^3.8.1 || ^4.0.0", + "seamless-immutable": "^7.1.3" + }, + "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" + } + }, + "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==" + }, + "node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true + }, + "node_modules/core-js-compat": { + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.42.0.tgz", + "integrity": "sha512-bQasjMfyDGyaeWKBIu33lHh9qlSR0MFE/Nmc6nMjf/iU9b3rSMdAYz1Baxrv4lPdGUsTqZudHA4jIGSJy0SWZQ==", + "dependencies": { + "browserslist": "^4.24.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-pure": { + "version": "3.42.0", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.42.0.tgz", + "integrity": "sha512-007bM04u91fF4kMgwom2I5cQxAFIy8jVulgr9eozILl/SZE53QOqnW/+vviC+wQWLv+AunBG+8Q0TLoeSsSxRQ==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "engines": { + "node": ">=8" + } + }, + "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==" + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.8.3", + "is-in-browser": "^1.0.2" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.3.1.tgz", + "integrity": "sha512-ZgW+Jgdd7i52AaLYCriF8Mxqft0gD/R9i9wi6RWBhs1pqdPEzPjym7rvRKi397WmQFf3SlyUsszhw+VVCbx79Q==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^3.1.2", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "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==" + }, + "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 + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.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, + "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==", + "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==", + "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==", + "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_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==" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "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==", + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "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==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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 + }, + "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==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, + "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==", + "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==", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==" + }, + "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==", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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 + }, + "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==" + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-helpers/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/dompurify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", + "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==" + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dot-prop/node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "engines": { + "node": ">=8" + } + }, + "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==" + }, + "node_modules/downshift": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/downshift/-/downshift-3.2.7.tgz", + "integrity": "sha512-mbUO9ZFhMGtksIeVWRFFjNOPN237VsUqZSEYi0VS0Wj38XNLzpgOBTUcUjdjFeB8KVgmrcRa6GGFkTbACpG6FA==", + "dependencies": { + "@babel/runtime": "^7.1.2", + "compute-scroll-into-view": "^1.0.9", + "prop-types": "^15.6.0", + "react-is": "^16.5.2" + }, + "peerDependencies": { + "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==" + }, + "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==", + "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.5", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", + "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.157", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.157.tgz", + "integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==" + }, + "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/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==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", + "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/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==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.10", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.10.tgz", + "integrity": "sha512-MtUbM072wlJNyeYAe0mhzrD+M6DIJa96CZAOBBrhDbgKnB4MApIKefcyAB1eOdYn8cUNZgvwBvEzdoAYsxgEIw==", + "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-regex": "^1.2.1", + "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", + "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==", + "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==", + "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==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "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 + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", + "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "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-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.6", + "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.4", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "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==", + "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==", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.25.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz", + "integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.4", + "@esbuild/android-arm": "0.25.4", + "@esbuild/android-arm64": "0.25.4", + "@esbuild/android-x64": "0.25.4", + "@esbuild/darwin-arm64": "0.25.4", + "@esbuild/darwin-x64": "0.25.4", + "@esbuild/freebsd-arm64": "0.25.4", + "@esbuild/freebsd-x64": "0.25.4", + "@esbuild/linux-arm": "0.25.4", + "@esbuild/linux-arm64": "0.25.4", + "@esbuild/linux-ia32": "0.25.4", + "@esbuild/linux-loong64": "0.25.4", + "@esbuild/linux-mips64el": "0.25.4", + "@esbuild/linux-ppc64": "0.25.4", + "@esbuild/linux-riscv64": "0.25.4", + "@esbuild/linux-s390x": "0.25.4", + "@esbuild/linux-x64": "0.25.4", + "@esbuild/netbsd-arm64": "0.25.4", + "@esbuild/netbsd-x64": "0.25.4", + "@esbuild/openbsd-arm64": "0.25.4", + "@esbuild/openbsd-x64": "0.25.4", + "@esbuild/sunos-x64": "0.25.4", + "@esbuild/win32-arm64": "0.25.4", + "@esbuild/win32-ia32": "0.25.4", + "@esbuild/win32-x64": "0.25.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "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", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "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", + "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", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "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", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "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==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.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==", + "dev": true, + "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" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "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.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/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, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "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", + "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" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "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.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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/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/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "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==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "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.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/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, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/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/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==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "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, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", + "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" + }, + "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==" + }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "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==", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "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==", + "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==" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "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" + }, + "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, + "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==" + }, + "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 + }, + "node_modules/fast-uri": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", + "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "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==", + "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, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "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==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/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==", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "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==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/final-form": { + "version": "4.20.10", + "resolved": "https://registry.npmjs.org/final-form/-/final-form-4.20.10.tgz", + "integrity": "sha512-TL48Pi1oNHeMOHrKv1bCJUrWZDcD3DIG6AGYVNOnyZPr7Bd/pStN0pL+lfzF5BNoj/FclaoiaLenk4XUIFVYng==", + "dependencies": { + "@babel/runtime": "^7.10.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/final-form" + } + }, + "node_modules/final-form-arrays": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/final-form-arrays/-/final-form-arrays-3.1.0.tgz", + "integrity": "sha512-TWBvun+AopgBLw9zfTFHBllnKMVNEwCEyDawphPuBGGqNsuhGzhT7yewHys64KFFwzIs6KEteGLpKOwvTQEscQ==", + "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==", + "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/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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==" + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "engines": { + "node": ">=6.9.0" + } + }, + "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==", + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==" + }, + "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==" + }, + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "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==", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "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==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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==", + "dependencies": { + "ini": "1.3.7" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "engines": { + "node": ">=4" + } + }, + "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==", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "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" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "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==" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/happy-dom": { + "version": "20.0.8", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz", + "integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/@types/node": { + "version": "20.19.23", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz", + "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/happy-dom/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "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==", + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "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==", + "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==", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/history": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "dependencies": { + "@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" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "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==" + }, + "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==" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "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 + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==" + }, + "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/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==" + }, + "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" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "optional": true + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A==", + "engines": { + "node": ">=4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "engines": { + "node": ">=0.8.19" + } + }, + "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==", + "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==", + "engines": { + "node": ">=18.0.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.", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "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==" + }, + "node_modules/inquirer": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", + "dependencies": { + "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" + }, + "engines": { + "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==", + "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==", + "dev": true, + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==" + }, + "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==", + "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" + } + }, + "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==", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "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==", + "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==", + "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==", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "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==", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "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==", + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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==" + }, + "node_modules/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==", + "dependencies": { + "global-dirs": "^2.0.1", + "is-path-inside": "^3.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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==", + "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==" + }, + "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==" + }, + "node_modules/is-npm": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", + "integrity": "sha512-96ECIfh9xtDDlPylNPXhzjsykHsMJZ18ASpaWzQyBr4YRTcVjUvzaHayDAES2oU/3KpljhHUjtSRNiDwi0F0ig==", + "engines": { + "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==", + "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==", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "engines": { + "node": ">=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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "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==", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "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==", + "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": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" + }, + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "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==", + "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==", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "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==", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==" + }, + "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==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "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==", + "dev": true, + "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==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "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==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "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, + "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/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/jake/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "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": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "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, + "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" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "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==", + "dev": true, + "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==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, + "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==" + }, + "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==" + }, + "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==", + "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 + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonexport": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jsonexport/-/jsonexport-2.5.2.tgz", + "integrity": "sha512-4joNLCxxUAmS22GN3GA5os/MYFnq8oqXOKvoCymmcT0MPz/QPZ5eA+Fh5sIPxUji45RKq8DdQ1yoKq91p4E9VA==", + "bin": { + "jsonexport": "bin/jsonexport.js" + } + }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "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==", + "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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "csstype": "^3.0.2", + "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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "hyphenate-style-name": "^1.0.3", + "jss": "10.10.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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "jss": "10.10.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==", + "dependencies": { + "@babel/runtime": "^7.3.1", + "css-vendor": "^2.0.8", + "jss": "10.10.0" + } + }, + "node_modules/jss/node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "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==", + "engines": { + "node": ">=18" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "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 + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/latest-version": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dependencies": { + "package-json": "^6.3.0" + }, + "engines": { + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "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==" + }, + "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/lodash": { + "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==" + }, + "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==" + }, + "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 + }, + "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==" + }, + "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==" + }, + "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==", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "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==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "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==", + "engines": { + "node": ">=8" + }, + "funding": { + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/meow": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/meow/-/meow-7.1.1.tgz", + "integrity": "sha512-GWHvA5QOcS412WCo8vwKDlTelGLsCGBVevQB5Kva961rmNfun0PCbv5+xta2kUMFJyR8/oWnn7ddeKdosbAPbA==", + "dependencies": { + "@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" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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, + "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==", + "dev": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.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==", + "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==", + "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==", + "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, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "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==" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/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==", + "dependencies": { + "@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" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "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==", + "dependencies": { + "hasown": "^2.0.2", + "object.entries": "^1.1.8", + "warning": "^4.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" + }, + "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==", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/normalize-package-data/node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dependencies": { + "is-core-module": "^2.16.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/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==", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "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, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "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==", + "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", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.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==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "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==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dependencies": { + "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" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "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==", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "engines": { + "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==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dependencies": { + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "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, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@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" + }, + "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==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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==", + "engines": { + "node": ">=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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "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==", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "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 + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "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==" + }, + "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==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "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, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "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==", + "dev": true, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "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==" + }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "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==", + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", + "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "dependencies": { + "escape-goat": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/query-string": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz", + "integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==", + "dependencies": { + "decode-uri-component": "^0.2.0", + "object-assign": "^4.1.0", + "strict-uri-encode": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "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/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ra-core": { + "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-core/-/ra-core-3.19.12.tgz", + "integrity": "sha512-E0cM6OjEUtccaR+dR5mL1MLiVVYML0Yf7aPhpLEq4iue73X3+CKcLztInoBhWgeevPbFQwgAtsXhlpedeyrNNg==", + "dependencies": { + "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" + }, + "peerDependencies": { + "connected-react-router": "^6.5.2", + "final-form": "^4.20.2", + "history": "^4.7.2", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-final-form": "^6.5.2", + "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" + } + }, + "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==" + }, + "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" + ] + }, + "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==", + "dependencies": { + "query-string": "^5.1.1", + "ra-core": "^3.19.12" + } + }, + "node_modules/ra-i18n-polyglot": { + "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-i18n-polyglot/-/ra-i18n-polyglot-3.19.12.tgz", + "integrity": "sha512-7VkNybY+RYVL5aDf8MdefYpRMkaELOjSXx7rrRY7PzVwmQzVe5ESoKBcH4Cob2M8a52pAlXY32dwmA3dZ91l/Q==", + "dependencies": { + "node-polyglot": "^2.2.2", + "ra-core": "^3.19.12" + } + }, + "node_modules/ra-language-english": { + "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-language-english/-/ra-language-english-3.19.12.tgz", + "integrity": "sha512-aYY0ma74eXLuflPT9iXEQtVEDZxebw1NiQZ5pPGiBCpsq+hoiDWuzerLU13OdBHbySD5FHLuk89SkyAdfMtUaQ==", + "dependencies": { + "ra-core": "^3.19.12" + } + }, + "node_modules/ra-test": { + "version": "3.19.12", + "resolved": "https://registry.npmjs.org/ra-test/-/ra-test-3.19.12.tgz", + "integrity": "sha512-SX6oi+VPADIeQeQlGWUVj2kgEYgLbizpzYMq+oacCmnAqvHezwnQ2MXrLDRK6C56YIl+t8DyY/ipYBiRPZnHbA==", + "dev": true, + "dependencies": { + "@testing-library/react": "^11.2.3", + "classnames": "~2.3.1", + "lodash": "~4.17.5" + }, + "peerDependencies": { + "ra-core": "^3.13.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-redux": "^7.1.0", + "react-router": "^5.1.0", + "react-router-dom": "^5.1.0", + "redux": "^3.7.2 || ^4.0.3" + } + }, + "node_modules/ra-test/node_modules/@testing-library/dom": { + "version": "7.31.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", + "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", + "dev": true, + "dependencies": { + "@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" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ra-test/node_modules/@testing-library/react": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-11.2.7.tgz", + "integrity": "sha512-tzRNp7pzd5QmbtXNG/mhdcl7Awfu/Iz1RaVHY75zTdOkmHCuzMhRL83gWHSgOAcjS3CCbyfwUHMZgRJb4kAfpA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^7.28.1" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "*", + "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==", + "dev": true + }, + "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, + "dependencies": { + "@babel/runtime": "^7.10.2", + "@babel/runtime-corejs3": "^7.10.2" + }, + "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 + }, + "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, + "dependencies": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "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==", + "dependencies": { + "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" + }, + "peerDependencies": { + "@material-ui/core": "^4.12.1", + "@material-ui/icons": "^4.11.2", + "@material-ui/styles": "^4.11.4", + "final-form": "^4.20.2", + "final-form-arrays": "^3.0.2", + "ra-core": "^3.14.0", + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0", + "react-final-form": "^6.5.2", + "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" + } + }, + "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==" + }, + "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" + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "dom-align": "^1.7.0", + "rc-util": "^5.26.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==", + "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" + } + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-tooltip": "^5.0.1", + "rc-util": "^5.16.1", + "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==", + "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==", + "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" + } + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.6", + "rc-align": "^4.0.0", + "rc-motion": "^2.0.0", + "rc-util": "^5.19.2" + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.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==" + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz", + "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-admin": { + "version": "3.19.12", + "resolved": "https://registry.npmjs.org/react-admin/-/react-admin-3.19.12.tgz", + "integrity": "sha512-LanWS3Yjie7n5GZI8v7oP73DSvQyCeZD0dpkC65IC0+UOhkInxa1zedJc8CyD3+ZwlgVC+CGqi6jQ1fo73Cdqw==", + "dependencies": { + "@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" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0", + "react-dom": "^16.9.0 || ^17.0.0" + } + }, + "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==", + "dependencies": { + "@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" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "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==", + "dependencies": { + "dnd-core": "14.0.1" + } + }, + "node_modules/react-dom": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz", + "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==", + "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==", + "dependencies": { + "babel-runtime": "^6.26.0", + "prop-types": "^15.5.8" + } + }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, + "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==", + "dependencies": { + "attr-accept": "^2.0.0", + "file-selector": "^0.1.12", + "prop-types": "^15.7.2" + }, + "engines": { + "node": ">= 8" + }, + "peerDependencies": { + "react": ">= 16.8" + } + }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, + "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==", + "dependencies": { + "@babel/runtime": "^7.15.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/final-form" + }, + "peerDependencies": { + "final-form": "^4.20.4", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-final-form-arrays": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-final-form-arrays/-/react-final-form-arrays-3.1.4.tgz", + "integrity": "sha512-siVFAolUAe29rMR6u8VwepoysUcUdh6MLV2OWnCtKpsPRUdT9VUgECjAPaVMAH2GROZNiVB9On1H9MMrm9gdpg==", + "dependencies": { + "@babel/runtime": "^7.19.4" + }, + "peerDependencies": { + "final-form": "^4.15.0", + "final-form-arrays": ">=1.0.4", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-final-form": "^6.2.1" + } + }, + "node_modules/react-ga": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", + "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", + "peerDependencies": { + "prop-types": "^15.6.0", + "react": "^15.6.2 || ^16.0 || ^17 || ^18" + } + }, + "node_modules/react-hotkeys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/react-hotkeys/-/react-hotkeys-2.0.0.tgz", + "integrity": "sha512-3n3OU8vLX/pfcJrR3xJ1zlww6KS1kEJt0Whxc4FiGV+MJrQ1mYSYI3qS/11d2MJDFm8IhOXMTFQirfu6AVOF6Q==", + "dependencies": { + "prop-types": "^15.6.1" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-image-lightbox": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/react-image-lightbox/-/react-image-lightbox-5.1.4.tgz", + "integrity": "sha512-kTiAODz091bgT7SlWNHab0LSMZAPJtlNWDGKv7pLlLY1krmf7FuG1zxE0wyPpeA8gPdwfr3cu6sPwZRqWsc3Eg==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "dependencies": { + "prop-types": "^15.7.2", + "react-modal": "^3.11.1" + }, + "peerDependencies": { + "react": "16.x || 17.x", + "react-dom": "16.x || 17.x" + } + }, + "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-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==" + }, + "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==", + "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==", + "dependencies": { + "exenv": "^1.2.0", + "prop-types": "^15.7.2", + "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==", + "dependencies": { + "@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" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "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==", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "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" + }, + "peerDependencies": { + "react": ">=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==", + "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", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "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==" + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "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", + "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==", + "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" + } + }, + "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==" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "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/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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", + "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.12.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/registry-auth-token": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.2.tgz", + "integrity": "sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg==", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/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==", + "dependencies": { + "rc": "^1.2.8" + }, + "engines": { + "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==" + }, + "node_modules/regjsparser": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", + "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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==" + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reselect": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-3.0.1.tgz", + "integrity": "sha512-b/6tFZCmRhtBMa4xGqiiRp9jh9Aqi2A687Lo265cN0/QohJQEBPiQ52f4QB6i0eF3yp3hmLL21LSGBcML2dlxA==" + }, + "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==" + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.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/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, + "engines": { + "node": ">=4" + } + }, + "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==" + }, + "node_modules/responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==", + "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==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "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, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "name": "@rollup/wasm-node", + "version": "4.41.1", + "resolved": "https://registry.npmjs.org/@rollup/wasm-node/-/wasm-node-4.41.1.tgz", + "integrity": "sha512-70qfem+U3hAgwNgOlnUQiIdfKHLELUxsEWbFWg3aErPUvsyXYF1HALJBwoDgMUhRWyn+SqWVneDTnO/Kbey9hg==", + "devOptional": true, + "dependencies": { + "@types/estree": "1.0.7" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.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==", + "dev": true + }, + "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==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", + "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==", + "dependencies": { + "tslib": "^1.9.0" + }, + "engines": { + "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==" + }, + "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==", + "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==" + }, + "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" + } + ] + }, + "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==", + "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==" + }, + "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==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz", + "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==", + "dependencies": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, + "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==", + "optional": true + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "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==", + "dependencies": { + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semver-diff/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "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==", + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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" + } + }, + "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==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==" + }, + "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==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "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" + } + }, + "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==", + "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==", + "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==", + "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 + }, + "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==" + }, + "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/smob": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", + "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==" + }, + "node_modules/sortablejs": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.6.tgz", + "integrity": "sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==" + }, + "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==", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "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==", + "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==", + "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==", + "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==" + }, + "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==", + "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" + }, + "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==", + "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==" + }, + "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==", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.21", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz", + "integrity": "sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg==" + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "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==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "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==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/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==" + }, + "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, + "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==", + "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" + }, + "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, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "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==", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "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==", + "engines": { + "node": ">=10" + } + }, + "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==", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dependencies": { + "has-flag": "^4.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==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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==", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.39.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.39.2.tgz", + "integrity": "sha512-yEPUmWve+VA78bI71BW70Dh0TuV4HHd+I5SHOAfS1+QBOmvmCiiffgjR8ryyEd3KIfvPGFqoADt8LdQ6XpXIvg==", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.14.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "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==", + "dev": true + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "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==" + }, + "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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "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, + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "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==", + "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==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "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==", + "dev": true, + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "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==" + }, + "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, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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==", + "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" + } + }, + "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==", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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" + } + }, + "node_modules/typescript-logic": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz", + "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==", + "license": "MIT" + }, + "node_modules/typescript-tuple": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz", + "integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==", + "license": "MIT", + "dependencies": { + "typescript-compare": "^0.0.2" + } + }, + "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==", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "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==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "engines": { + "node": ">=4" + } + }, + "node_modules/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==", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-notifier": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-4.1.3.tgz", + "integrity": "sha512-Yld6Z0RyCYGB6ckIjffGOSOmHXj1gMeE7aROz4MG+XMkmixBX4jUngrGXNYz7wPKBmtoD4MnBa2Anu7RSKht/A==", + "dependencies": { + "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": ">=8" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.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": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==", + "dependencies": { + "prepend-http": "^2.0.0" + }, + "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==" + }, + "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", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "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==", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "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==" + }, + "node_modules/vite": { + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.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.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.1.0.tgz", + "integrity": "sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==", + "dev": true, + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.3.0", + "workbox-window": "^7.3.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.3.0", + "workbox-window": "^7.3.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, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz", + "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.0.3", + "@vitest/mocker": "4.0.3", + "@vitest/pretty-format": "4.0.3", + "@vitest/runner": "4.0.3", + "@vitest/snapshot": "4.0.3", + "@vitest/spy": "4.0.3", + "@vitest/utils": "4.0.3", + "debug": "^4.4.3", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.19", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.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": "*", + "@types/debug": "^4.1.12", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.3", + "@vitest/browser-preview": "4.0.3", + "@vitest/browser-webdriverio": "4.0.3", + "@vitest/ui": "4.0.3", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "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 + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^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==", + "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==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "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==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "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, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "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==", + "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==" + }, + "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==", + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "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==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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==", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-background-sync": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz", + "integrity": "sha512-PCSk3eK7Mxeuyatb22pcSx9dlgWNv3+M8PqPaYDokks8Y5/FX4soaOqj3yhAZr5k6Q5JWTOMYgaJBpbw11G9Eg==", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.3.0.tgz", + "integrity": "sha512-T9/F5VEdJVhwmrIAE+E/kq5at2OY6+OXXgOWQevnubal6sO92Gjo24v6dCVwQiclAF5NS3hlmsifRrpQzZCdUA==", + "dependencies": { + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-build": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.3.0.tgz", + "integrity": "sha512-JGL6vZTPlxnlqZRhR/K/msqg3wKP+m0wfEUVosK7gsYzSgeIxvZLi1ViJJzVL7CEeI8r7rGFV973RiEqkP3lWQ==", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@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-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": "^7.1.6", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.43.1", + "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.3.0", + "workbox-broadcast-update": "7.3.0", + "workbox-cacheable-response": "7.3.0", + "workbox-core": "7.3.0", + "workbox-expiration": "7.3.0", + "workbox-google-analytics": "7.3.0", + "workbox-navigation-preload": "7.3.0", + "workbox-precaching": "7.3.0", + "workbox-range-requests": "7.3.0", + "workbox-recipes": "7.3.0", + "workbox-routing": "7.3.0", + "workbox-strategies": "7.3.0", + "workbox-streams": "7.3.0", + "workbox-sw": "7.3.0", + "workbox-window": "7.3.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/workbox-build/node_modules/@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==", + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "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==", + "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/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==", + "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==", + "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==" + }, + "node_modules/workbox-build/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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==" + }, + "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==" + }, + "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==", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-build/node_modules/rollup": { + "version": "2.79.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz", + "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.3.0.tgz", + "integrity": "sha512-eAFERIg6J2LuyELhLlmeRcJFa5e16Mj8kL2yCDbhWE+HUun9skRQrGIFVUagqWj4DMaaPSMWfAolM7XZZxNmxA==", + "dependencies": { + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-cli": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-cli/-/workbox-cli-7.3.0.tgz", + "integrity": "sha512-dB2Yz4s3PWcb2daHLUQC3Q0P+WGeoOKR6+LQqZ7ciWOHMhaWj7sWmomELa4IMVlNat53EF8MXOpXx2Ggd1o7+w==", + "dependencies": { + "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": "7.3.0" + }, + "bin": { + "workbox": "build/bin.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-core": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.3.0.tgz", + "integrity": "sha512-Z+mYrErfh4t3zi7NVTvOuACB0A/jA3bgxUN3PwtAVHvfEsZxV9Iju580VEETug3zYJRc0Dmii/aixI/Uxj8fmw==" + }, + "node_modules/workbox-expiration": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.3.0.tgz", + "integrity": "sha512-lpnSSLp2BM+K6bgFCWc5bS1LR5pAwDWbcKt1iL87/eTSJRdLdAwGQznZE+1czLgn/X05YChsrEegTNxjM067vQ==", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.3.0.tgz", + "integrity": "sha512-ii/tSfFdhjLHZ2BrYgFNTrb/yk04pw2hasgbM70jpZfLk0vdJAXgaiMAWsoE+wfJDNWoZmBYY0hMVI0v5wWDbg==", + "dependencies": { + "workbox-background-sync": "7.3.0", + "workbox-core": "7.3.0", + "workbox-routing": "7.3.0", + "workbox-strategies": "7.3.0" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.3.0.tgz", + "integrity": "sha512-fTJzogmFaTv4bShZ6aA7Bfj4Cewaq5rp30qcxl2iYM45YD79rKIhvzNHiFj1P+u5ZZldroqhASXwwoyusnr2cg==", + "dependencies": { + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-precaching": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.3.0.tgz", + "integrity": "sha512-ckp/3t0msgXclVAYaNndAGeAoWQUv7Rwc4fdhWL69CCAb2UHo3Cef0KIUctqfQj1p8h6aGyz3w8Cy3Ihq9OmIw==", + "dependencies": { + "workbox-core": "7.3.0", + "workbox-routing": "7.3.0", + "workbox-strategies": "7.3.0" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.3.0.tgz", + "integrity": "sha512-EyFmM1KpDzzAouNF3+EWa15yDEenwxoeXu9bgxOEYnFfCxns7eAxA9WSSaVd8kujFFt3eIbShNqa4hLQNFvmVQ==", + "dependencies": { + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-recipes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.3.0.tgz", + "integrity": "sha512-BJro/MpuW35I/zjZQBcoxsctgeB+kyb2JAP5EB3EYzePg8wDGoQuUdyYQS+CheTb+GhqJeWmVs3QxLI8EBP1sg==", + "dependencies": { + "workbox-cacheable-response": "7.3.0", + "workbox-core": "7.3.0", + "workbox-expiration": "7.3.0", + "workbox-precaching": "7.3.0", + "workbox-routing": "7.3.0", + "workbox-strategies": "7.3.0" + } + }, + "node_modules/workbox-routing": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.3.0.tgz", + "integrity": "sha512-ZUlysUVn5ZUzMOmQN3bqu+gK98vNfgX/gSTZ127izJg/pMMy4LryAthnYtjuqcjkN4HEAx1mdgxNiKJMZQM76A==", + "dependencies": { + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-strategies": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.3.0.tgz", + "integrity": "sha512-tmZydug+qzDFATwX7QiEL5Hdf7FrkhjaF9db1CbB39sDmEZJg3l9ayDvPxy8Y18C3Y66Nrr9kkN1f/RlkDgllg==", + "dependencies": { + "workbox-core": "7.3.0" + } + }, + "node_modules/workbox-streams": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.3.0.tgz", + "integrity": "sha512-SZnXucyg8x2Y61VGtDjKPO5EgPUG5NDn/v86WYHX+9ZqvAsGOytP0Jxp1bl663YUuMoXSAtsGLL+byHzEuMRpw==", + "dependencies": { + "workbox-core": "7.3.0", + "workbox-routing": "7.3.0" + } + }, + "node_modules/workbox-sw": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.3.0.tgz", + "integrity": "sha512-aCUyoAZU9IZtH05mn0ACUpyHzPs0lMeJimAYkQkBsOWiqaJLgusfDCR+yllkPkFRxWpZKF8vSvgHYeG7LwhlmA==" + }, + "node_modules/workbox-window": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.3.0.tgz", + "integrity": "sha512-qW8PDy16OV1UBaUNGlTVcepzrlzyzNW/ZJvFQQs2j2TzGsg6IKjcpZC1RSquqQnTOafl5pCj5bGfAHlCjOOjdA==", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.3.0" + } + }, + "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==" + }, + "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==", + "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.18.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", + "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/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "engines": { + "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, + "engines": { + "node": ">=18" + } + }, + "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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "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==", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "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, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..a3612aa --- /dev/null +++ b/ui/package.json @@ -0,0 +1,85 @@ +{ + "name": "ui", + "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": { + "@material-ui/core": "^4.12.4", + "@material-ui/icons": "^4.11.3", + "@material-ui/lab": "^4.0.0-alpha.61", + "@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", + "history": "^4.10.1", + "inflection": "^3.0.2", + "jwt-decode": "^4.0.0", + "lodash.throttle": "^4.1.1", + "navidrome-music-player": "4.25.1", + "prop-types": "^15.8.1", + "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-dom": "^17.0.2", + "react-drag-listview": "^0.1.9", + "react-ga": "^3.3.1", + "react-hotkeys": "^2.0.0", + "react-icons": "^5.5.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" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@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", + "ra-test": "^3.19.12", + "typescript": "^5.8.3", + "vite": "^7.1.12", + "vite-plugin-pwa": "^1.1.0", + "vitest": "^4.0.3" + }, + "overrides": { + "vite": { + "rollup": "npm:@rollup/wasm-node" + } + } +} diff --git a/ui/prettier.config.js b/ui/prettier.config.js new file mode 100644 index 0000000..723744a --- /dev/null +++ b/ui/prettier.config.js @@ -0,0 +1,5 @@ +export default { + singleQuote: true, + semi: false, + arrowParens: "always", +}; diff --git a/ui/public/.gitkeep b/ui/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ui/public/android-chrome-192x192.png b/ui/public/android-chrome-192x192.png new file mode 100644 index 0000000..07c10ba Binary files /dev/null and b/ui/public/android-chrome-192x192.png differ diff --git a/ui/public/android-chrome-512x512.png b/ui/public/android-chrome-512x512.png new file mode 100644 index 0000000..01612b2 Binary files /dev/null and b/ui/public/android-chrome-512x512.png differ diff --git a/ui/public/apple-touch-icon-120x120.png b/ui/public/apple-touch-icon-120x120.png new file mode 100644 index 0000000..c4a2934 Binary files /dev/null and b/ui/public/apple-touch-icon-120x120.png differ diff --git a/ui/public/apple-touch-icon-152x152.png b/ui/public/apple-touch-icon-152x152.png new file mode 100644 index 0000000..c59afb2 Binary files /dev/null and b/ui/public/apple-touch-icon-152x152.png differ diff --git a/ui/public/apple-touch-icon-180x180.png b/ui/public/apple-touch-icon-180x180.png new file mode 100644 index 0000000..f3a205f Binary files /dev/null and b/ui/public/apple-touch-icon-180x180.png differ diff --git a/ui/public/apple-touch-icon-60x60.png b/ui/public/apple-touch-icon-60x60.png new file mode 100644 index 0000000..16b1cf7 Binary files /dev/null and b/ui/public/apple-touch-icon-60x60.png differ diff --git a/ui/public/apple-touch-icon-76x76.png b/ui/public/apple-touch-icon-76x76.png new file mode 100644 index 0000000..53ba3ad Binary files /dev/null and b/ui/public/apple-touch-icon-76x76.png differ diff --git a/ui/public/apple-touch-icon.png b/ui/public/apple-touch-icon.png new file mode 100644 index 0000000..5a57df8 Binary files /dev/null and b/ui/public/apple-touch-icon.png differ diff --git a/ui/public/browserconfig.xml b/ui/public/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/ui/public/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #da532c + + + diff --git a/ui/public/favicon-16x16.png b/ui/public/favicon-16x16.png new file mode 100644 index 0000000..ac27131 Binary files /dev/null and b/ui/public/favicon-16x16.png differ diff --git a/ui/public/favicon-32x32.png b/ui/public/favicon-32x32.png new file mode 100644 index 0000000..e12663a Binary files /dev/null and b/ui/public/favicon-32x32.png differ diff --git a/ui/public/favicon.ico b/ui/public/favicon.ico new file mode 100644 index 0000000..3ccc575 Binary files /dev/null and b/ui/public/favicon.ico differ diff --git a/ui/public/internet-radio-icon.svg b/ui/public/internet-radio-icon.svg new file mode 100644 index 0000000..d658eab --- /dev/null +++ b/ui/public/internet-radio-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/public/mstile-144x144.png b/ui/public/mstile-144x144.png new file mode 100644 index 0000000..bd9e5c6 Binary files /dev/null and b/ui/public/mstile-144x144.png differ diff --git a/ui/public/mstile-150x150.png b/ui/public/mstile-150x150.png new file mode 100644 index 0000000..565a44b Binary files /dev/null and b/ui/public/mstile-150x150.png differ diff --git a/ui/public/mstile-310x150.png b/ui/public/mstile-310x150.png new file mode 100644 index 0000000..23f98ee Binary files /dev/null and b/ui/public/mstile-310x150.png differ diff --git a/ui/public/mstile-310x310.png b/ui/public/mstile-310x310.png new file mode 100644 index 0000000..4db9b7a Binary files /dev/null and b/ui/public/mstile-310x310.png differ diff --git a/ui/public/mstile-70x70.png b/ui/public/mstile-70x70.png new file mode 100644 index 0000000..d96d924 Binary files /dev/null and b/ui/public/mstile-70x70.png differ diff --git a/ui/public/offline.html b/ui/public/offline.html new file mode 100644 index 0000000..72f2d04 --- /dev/null +++ b/ui/public/offline.html @@ -0,0 +1,10 @@ + + +Navidrome + +

+It looks like we are having trouble connecting. +
+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 new file mode 100644 index 0000000..77470cb --- /dev/null +++ b/ui/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: / \ No newline at end of file diff --git a/ui/public/safari-pinned-tab.svg b/ui/public/safari-pinned-tab.svg new file mode 100644 index 0000000..0c359b8 --- /dev/null +++ b/ui/public/safari-pinned-tab.svg @@ -0,0 +1,60 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + + + + + + diff --git a/ui/src/App.jsx b/ui/src/App.jsx new file mode 100644 index 0000000..dc4fe9b --- /dev/null +++ b/ui/src/App.jsx @@ -0,0 +1,172 @@ +import ReactGA from 'react-ga' +import { Provider } from 'react-redux' +import { createHashHistory } from 'history' +import { Admin as RAAdmin, Resource } from 'react-admin' +import { HotKeys } from 'react-hotkeys' +import dataProvider from './dataProvider' +import authProvider from './authProvider' +import { Layout, Login, Logout } from './layout' +import transcoding from './transcoding' +import player from './player' +import user from './user' +import song from './song' +import album from './album' +import artist from './artist' +import playlist from './playlist' +import radio from './radio' +import share from './share' +import library from './library' +import { Player } from './audioplayer' +import customRoutes from './routes' +import { + libraryReducer, + themeReducer, + addToPlaylistDialogReducer, + expandInfoDialogReducer, + listenBrainzTokenDialogReducer, + saveQueueDialogReducer, + playerReducer, + albumViewReducer, + activityReducer, + settingsReducer, + replayGainReducer, + downloadMenuDialogReducer, + shareDialogReducer, +} from './reducers' +import createAdminStore from './store/createAdminStore' +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' + +const history = createHashHistory() + +if (config.gaTrackingId) { + ReactGA.initialize(config.gaTrackingId) + history.listen((location) => { + ReactGA.pageview(location.pathname) + }) + ReactGA.pageview(window.location.pathname) +} + +const adminStore = createAdminStore({ + authProvider, + dataProvider, + history, + customReducers: { + library: libraryReducer, + player: playerReducer, + albumView: albumViewReducer, + theme: themeReducer, + addToPlaylistDialog: addToPlaylistDialogReducer, + downloadMenuDialog: downloadMenuDialogReducer, + expandInfoDialog: expandInfoDialogReducer, + listenBrainzTokenDialog: listenBrainzTokenDialogReducer, + saveQueueDialog: saveQueueDialogReducer, + shareDialog: shareDialogReducer, + activity: activityReducer, + settings: settingsReducer, + replayGain: replayGainReducer, + }, +}) + +const App = () => ( + + + +) + +const Admin = (props) => { + useChangeThemeColor() + /* eslint-disable react/jsx-key */ + return ( + + {(permissions) => [ + , + , + , + , + config.enableSharing && , + , + , + , + permissions === 'admin' ? ( + + ) : ( + + ), + permissions === 'admin' ? ( + + ) : null, + permissions === 'admin' ? ( + + ) : null, + + , + , + , + , + , + , + , + , + ]} + + ) + /* eslint-enable react/jsx-key */ +} + +const AppWithHotkeys = () => { + let language = localStorage.getItem('locale') || 'en' + document.documentElement.lang = language + if (config.enableSharing && shareInfo) { + return + } + return ( + + + + + + ) +} + +export default AppWithHotkeys diff --git a/ui/src/actions/albumView.js b/ui/src/actions/albumView.js new file mode 100644 index 0000000..743373d --- /dev/null +++ b/ui/src/actions/albumView.js @@ -0,0 +1,6 @@ +export const ALBUM_MODE_GRID = 'ALBUM_GRID_MODE' +export const ALBUM_MODE_TABLE = 'ALBUM_TABLE_MODE' + +export const albumViewGrid = () => ({ type: ALBUM_MODE_GRID }) + +export const albumViewTable = () => ({ type: ALBUM_MODE_TABLE }) diff --git a/ui/src/actions/dialogs.js b/ui/src/actions/dialogs.js new file mode 100644 index 0000000..dea4d62 --- /dev/null +++ b/ui/src/actions/dialogs.js @@ -0,0 +1,88 @@ +export const ADD_TO_PLAYLIST_OPEN = 'ADD_TO_PLAYLIST_OPEN' +export const ADD_TO_PLAYLIST_CLOSE = 'ADD_TO_PLAYLIST_CLOSE' +export const DOWNLOAD_MENU_OPEN = 'DOWNLOAD_MENU_OPEN' +export const DOWNLOAD_MENU_CLOSE = 'DOWNLOAD_MENU_CLOSE' +export const DUPLICATE_SONG_WARNING_OPEN = 'DUPLICATE_SONG_WARNING_OPEN' +export const DUPLICATE_SONG_WARNING_CLOSE = 'DUPLICATE_SONG_WARNING_CLOSE' +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' +export const DOWNLOAD_MENU_SONG = 'song' +export const SHARE_MENU_OPEN = 'SHARE_MENU_OPEN' +export const SHARE_MENU_CLOSE = 'SHARE_MENU_CLOSE' + +export const openShareMenu = (ids, resource, name, label) => ({ + type: SHARE_MENU_OPEN, + ids, + resource, + name, + label, +}) + +export const closeShareMenu = () => ({ + type: SHARE_MENU_CLOSE, +}) + +export const openAddToPlaylist = ({ selectedIds, onSuccess }) => ({ + type: ADD_TO_PLAYLIST_OPEN, + selectedIds, + onSuccess, +}) + +export const closeAddToPlaylist = () => ({ + type: ADD_TO_PLAYLIST_CLOSE, +}) + +export const openDownloadMenu = (record, recordType) => { + return { + type: DOWNLOAD_MENU_OPEN, + recordType, + record, + } +} + +export const closeDownloadMenu = () => ({ + type: DOWNLOAD_MENU_CLOSE, +}) + +export const openDuplicateSongWarning = (duplicateIds) => ({ + type: DUPLICATE_SONG_WARNING_OPEN, + duplicateIds, +}) + +export const closeDuplicateSongDialog = () => ({ + type: DUPLICATE_SONG_WARNING_CLOSE, +}) + +export const openExtendedInfoDialog = (record) => { + return { + type: EXTENDED_INFO_OPEN, + record, + } +} + +export const closeExtendedInfoDialog = () => ({ + type: EXTENDED_INFO_CLOSE, +}) + +export const openListenBrainzTokenDialog = () => ({ + type: LISTENBRAINZ_TOKEN_OPEN, +}) + +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 new file mode 100644 index 0000000..9f35f86 --- /dev/null +++ b/ui/src/actions/index.js @@ -0,0 +1,8 @@ +export * from './library' +export * from './player' +export * from './themes' +export * from './albumView' +export * from './dialogs' +export * from './replayGain' +export * from './serverEvents' +export * from './settings' diff --git a/ui/src/actions/library.js b/ui/src/actions/library.js new file mode 100644 index 0000000..4653ec7 --- /dev/null +++ b/ui/src/actions/library.js @@ -0,0 +1,12 @@ +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 new file mode 100644 index 0000000..acef2e9 --- /dev/null +++ b/ui/src/actions/player.js @@ -0,0 +1,104 @@ +export const PLAYER_ADD_TRACKS = 'PLAYER_ADD_TRACKS' +export const PLAYER_PLAY_NEXT = 'PLAYER_PLAY_NEXT' +export const PLAYER_SET_TRACK = 'PLAYER_SET_TRACK' +export const PLAYER_SYNC_QUEUE = 'PLAYER_SYNC_QUEUE' +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 setTrack = (data) => ({ + type: PLAYER_SET_TRACK, + 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 + }, {}) +} + +export const addTracks = (data, ids) => { + const songs = filterSongs(data, ids) + return { + type: PLAYER_ADD_TRACKS, + data: songs, + } +} + +export const playNext = (data, ids) => { + const songs = filterSongs(data, ids) + return { + type: PLAYER_PLAY_NEXT, + data: songs, + } +} + +export const shuffle = (data) => { + const ids = Object.keys(data) + for (let i = ids.length - 1; i > 0; i--) { + let j = Math.floor(Math.random() * (i + 1)) + ;[ids[i], ids[j]] = [ids[j], ids[i]] + } + const shuffled = {} + // The "_" is to force the object key to be a string, so it keeps the order when adding to object + // or else the keys will always be in the same (numerically) order + ids.forEach((id) => (shuffled['_' + id] = data[id])) + return shuffled +} + +export const shuffleTracks = (data, ids) => { + const songs = filterSongs(data, ids) + const shuffled = shuffle(songs) + const firstId = Object.keys(shuffled)[0] + return { + type: PLAYER_PLAY_TRACKS, + id: firstId, + data: shuffled, + } +} + +export const playTracks = (data, ids, selectedId) => { + const songs = filterSongs(data, ids) + return { + type: PLAYER_PLAY_TRACKS, + id: selectedId || Object.keys(songs)[0], + data: songs, + } +} + +export const syncQueue = (audioInfo, audioLists) => ({ + type: PLAYER_SYNC_QUEUE, + data: { + audioInfo, + audioLists, + }, +}) + +export const clearQueue = () => ({ + type: PLAYER_CLEAR_QUEUE, +}) + +export const currentPlaying = (audioInfo) => ({ + type: PLAYER_CURRENT, + data: audioInfo, +}) + +export const setVolume = (volume) => ({ + type: PLAYER_SET_VOLUME, + data: { volume }, +}) + +export const setPlayMode = (mode) => ({ + type: PLAYER_SET_MODE, + data: { mode }, +}) diff --git a/ui/src/actions/replayGain.js b/ui/src/actions/replayGain.js new file mode 100644 index 0000000..a41af8d --- /dev/null +++ b/ui/src/actions/replayGain.js @@ -0,0 +1,12 @@ +export const CHANGE_GAIN = 'CHANGE_GAIN' +export const CHANGE_PREAMP = 'CHANGE_PREAMP' + +export const changeGain = (gain) => ({ + type: CHANGE_GAIN, + payload: gain, +}) + +export const changePreamp = (preamp) => ({ + type: CHANGE_PREAMP, + payload: preamp, +}) diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js new file mode 100644 index 0000000..9955345 --- /dev/null +++ b/ui/src/actions/serverEvents.js @@ -0,0 +1,29 @@ +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_STREAM_RECONNECTED = 'streamReconnected' + +export const processEvent = (type, data) => ({ + type, + data: data, +}) +export const scanStatusUpdate = (data) => ({ + type: EVENT_SCAN_STATUS, + data: data, +}) + +export const nowPlayingCountUpdate = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT, + 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 new file mode 100644 index 0000000..e62ecde --- /dev/null +++ b/ui/src/actions/settings.js @@ -0,0 +1,18 @@ +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 setNotificationsState = (enabled) => ({ + type: SET_NOTIFICATIONS_STATE, + data: enabled, +}) + +export const setToggleableFields = (obj) => ({ + type: SET_TOGGLEABLE_FIELDS, + data: obj, +}) + +export const setOmittedFields = (obj) => ({ + type: SET_OMITTED_FIELDS, + data: obj, +}) diff --git a/ui/src/actions/themes.js b/ui/src/actions/themes.js new file mode 100644 index 0000000..f41bc2f --- /dev/null +++ b/ui/src/actions/themes.js @@ -0,0 +1,6 @@ +export const CHANGE_THEME = 'CHANGE_THEME' + +export const changeTheme = (theme) => ({ + type: CHANGE_THEME, + payload: theme, +}) diff --git a/ui/src/album/AlbumActions.jsx b/ui/src/album/AlbumActions.jsx new file mode 100644 index 0000000..96cfab0 --- /dev/null +++ b/ui/src/album/AlbumActions.jsx @@ -0,0 +1,158 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' +import { + Button, + sanitizeListRestProps, + TopToolbar, + useRecordContext, + useTranslate, +} from 'react-admin' +import { useMediaQuery, makeStyles } from '@material-ui/core' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import ShuffleIcon from '@material-ui/icons/Shuffle' +import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' +import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri' +import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd' +import ShareIcon from '@material-ui/icons/Share' +import { + playNext, + addTracks, + playTracks, + shuffleTracks, + openAddToPlaylist, + openDownloadMenu, + DOWNLOAD_MENU_ALBUM, + openShareMenu, +} from '../actions' +import { formatBytes } from '../utils' +import config from '../config' +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, + data, + record, + permanentFilter, + ...rest +}) => { + const dispatch = useDispatch() + const translate = useTranslate() + const classes = useStyles() + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + + const handlePlay = React.useCallback(() => { + dispatch(playTracks(data, ids)) + }, [dispatch, data, ids]) + + const handlePlayNext = React.useCallback(() => { + dispatch(playNext(data, ids)) + }, [dispatch, data, ids]) + + const handlePlayLater = React.useCallback(() => { + dispatch(addTracks(data, ids)) + }, [dispatch, data, ids]) + + const handleShuffle = React.useCallback(() => { + dispatch(shuffleTracks(data, ids)) + }, [dispatch, data, ids]) + + const handleAddToPlaylist = React.useCallback(() => { + const selectedIds = ids.filter((id) => !data[id].missing) + dispatch(openAddToPlaylist({ selectedIds })) + }, [dispatch, data, ids]) + + const handleShare = React.useCallback(() => { + dispatch(openShareMenu([record.id], 'album', record.name)) + }, [dispatch, record]) + + const handleDownload = React.useCallback(() => { + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_ALBUM)) + }, [dispatch, record]) + + return ( + +
+
+ + + + + + + + + + + + + + + + {config.enableSharing && ( + + + + )} + {config.enableDownloads && ( + + + + )} +
+
{isNotSmall && }
+
+
+ ) +} + +AlbumActions.propTypes = { + record: PropTypes.object.isRequired, + selectedIds: PropTypes.arrayOf(PropTypes.number), +} + +AlbumActions.defaultProps = { + record: {}, + selectedIds: [], +} + +export default AlbumActions diff --git a/ui/src/album/AlbumDatesField.jsx b/ui/src/album/AlbumDatesField.jsx new file mode 100644 index 0000000..ce13013 --- /dev/null +++ b/ui/src/album/AlbumDatesField.jsx @@ -0,0 +1,25 @@ +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 new file mode 100644 index 0000000..9bcd415 --- /dev/null +++ b/ui/src/album/AlbumDatesField.test.jsx @@ -0,0 +1,112 @@ +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.jsx new file mode 100644 index 0000000..8213eb9 --- /dev/null +++ b/ui/src/album/AlbumDetails.jsx @@ -0,0 +1,392 @@ +import { useCallback, useEffect, useState } from 'react' +import { + Card, + CardContent, + CardMedia, + Collapse, + makeStyles, + Typography, + useMediaQuery, + withWidth, +} from '@material-ui/core' +import { + ArrayField, + ChipField, + Link, + SingleFieldList, + useRecordContext, + useTranslate, +} from 'react-admin' +import Lightbox from 'react-image-lightbox' +import 'react-image-lightbox/style.css' +import subsonic from '../subsonic' +import { + ArtistLinkField, + CollapsibleComment, + DurationField, + formatRange, + LoveButton, + RatingField, + SizeField, + useAlbumsPerPage, +} from '../common' +import config from '../config' +import { formatFullDate, intersperse } from '../utils' +import AlbumExternalLinks from './AlbumExternalLinks' + +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', + }, + cover: { + objectFit: 'contain', + cursor: 'pointer', + display: 'block', + width: '100%', + height: '100%', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, + }, + loveButton: { + top: theme.spacing(-0.2), + left: theme.spacing(0.5), + }, + notes: { + display: 'inline-block', + marginTop: '1em', + float: 'left', + wordBreak: 'break-word', + cursor: 'pointer', + }, + recordName: {}, + recordArtist: {}, + recordMeta: {}, + genreList: { + marginTop: theme.spacing(0.5), + }, + externalLinks: { + marginTop: theme.spacing(1.5), + }, + }), + { + name: 'NDAlbumDetails', + }, +) + +const useGetHandleGenreClick = (width) => { + const [perPage] = useAlbumsPerPage(width) + + return (id) => { + return `/album?filter={"genre_id":["${id}"]}&order=ASC&sort=name&perPage=${perPage}` + } +} + +const GenreChipField = withWidth()(({ width, ...rest }) => { + const record = useRecordContext(rest) + const genreLink = useGetHandleGenreClick(width) + + return ( + e.stopPropagation()}> + {}} + /> + + ) +}) + +const GenreList = () => { + const classes = useStyles() + return ( + + + + + + ) +} + +export const Details = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const translate = useTranslate() + const record = useRecordContext(props) + + // 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) : yearRange + + const originalDate = record.originalDate + ? formatFullDate(record.originalDate) + : formatRange(record, 'originalYear') + const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate) + + const dateToUse = originalDate || date + const isOriginalDate = originalDate && dateToUse !== date + const showDate = dateToUse && dateToUse !== releaseDate + + // 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) { + addDetail( + <>{[getReleaseDateLabel(), releaseDate].filter(Boolean).join(' ')}, + ) + } + addDetail( + <> + {record.songCount + + ' ' + + translate('resources.song.name', { + smart_count: record.songCount, + })} + , + ) + !isXsmall && addDetail() + !isXsmall && addDetail() + + // Return the details rendered with separators + return <>{intersperse(details, ' · ')} +} + +const AlbumDetails = (props) => { + const record = useRecordContext(props) + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) + const classes = useStyles() + const [isLightboxOpen, setLightboxOpen] = useState(false) + const [expanded, setExpanded] = useState(false) + const [albumInfo, setAlbumInfo] = useState() + const [imageLoading, setImageLoading] = useState(false) + const [imageError, setImageError] = useState(false) + + let notes = + albumInfo?.notes?.replace(new RegExp('<.*>', 'g'), '') || record.notes + + if (notes !== undefined) { + notes += '..' + } + + useEffect(() => { + subsonic + .getAlbumInfo(record.id) + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + setAlbumInfo(data.albumInfo) + } + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error('error on album page', e) + }) + }, [record]) + + // Reset image state when album changes + useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const imageUrl = subsonic.getCoverArtUrl(record, 300) + const fullImageUrl = subsonic.getCoverArtUrl(record) + + 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 ( + +
+
+ +
+
+ + + {record.name} + + + + {record?.tags?.['albumversion']} + + + + + +
+ + {config.enableStarRating && ( +
+ +
+ )} + {isDesktop ? ( + + ) : ( + {record.genre} + )} + {!isXsmall && ( + + {config.enableExternalServices && ( + + )} + + )} + {isDesktop && ( + + setExpanded(!expanded)} + > + + + + )} + {isDesktop && record['comment'] && ( + + )} + +
+
+ {!isDesktop && record['comment'] && ( + + )} + {!isDesktop && ( +
+ + setExpanded(!expanded)} + > + + + +
+ )} + {isLightboxOpen && !imageError && ( + + )} +
+ ) +} + +export default AlbumDetails diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx new file mode 100644 index 0000000..4840454 --- /dev/null +++ b/ui/src/album/AlbumDetails.test.jsx @@ -0,0 +1,345 @@ +// 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 { Details } from './AlbumDetails' + +// 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() + }) + }) +}) diff --git a/ui/src/album/AlbumExternalLinks.jsx b/ui/src/album/AlbumExternalLinks.jsx new file mode 100644 index 0000000..4b956b4 --- /dev/null +++ b/ui/src/album/AlbumExternalLinks.jsx @@ -0,0 +1,52 @@ +import React from 'react' +import { useRecordContext, useTranslate } from 'react-admin' +import { IconButton, Tooltip, Link } from '@material-ui/core' +import { ImLastfm2 } from 'react-icons/im' +import MusicBrainz from '../icons/MusicBrainz' +import { intersperse } from '../utils' +import config from '../config' + +const AlbumExternalLinks = (props) => { + const { className } = props + const translate = useTranslate() + const record = useRecordContext(props) + let links = [] + + const addLink = (url, title, icon) => { + const translatedTitle = translate(title) + const link = ( + + + + {icon} + + + + ) + const id = links.length + links.push({link}) + } + + if (config.lastFMEnabled) { + addLink( + `https://last.fm/music/${ + encodeURIComponent(record.albumArtist) + + '/' + + encodeURIComponent(record.name) + }`, + 'message.openIn.lastfm', + , + ) + } + + record.mbzAlbumId && + addLink( + `https://musicbrainz.org/release/${record.mbzAlbumId}`, + 'message.openIn.musicbrainz', + , + ) + + return
{intersperse(links, ' ')}
+} + +export default AlbumExternalLinks diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx new file mode 100644 index 0000000..58732bb --- /dev/null +++ b/ui/src/album/AlbumGridView.jsx @@ -0,0 +1,252 @@ +import React from 'react' +import { + GridList, + GridListTile, + Typography, + GridListTileBar, + useMediaQuery, +} from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +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 { useDrag } from 'react-dnd' +import subsonic from '../subsonic' +import { AlbumContextMenu, PlayButton, ArtistLinkField } from '../common' +import { DraggableTypes } from '../consts' +import clsx from 'clsx' +import { AlbumDatesField } from './AlbumDatesField.jsx' + +const useStyles = makeStyles( + (theme) => ({ + root: { + margin: '20px', + display: 'grid', + }, + tileBar: { + transition: 'all 150ms ease-out', + opacity: 0, + 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%)', + }, + albumArtistName: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + textAlign: 'left', + fontSize: '1em', + }, + albumName: { + fontSize: '14px', + color: theme.palette.type === 'dark' ? '#eee' : 'black', + overflow: 'hidden', + 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', + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + }, + link: { + position: 'relative', + display: 'block', + textDecoration: 'none', + '&:hover $tileBar': { + opacity: 1, + }, + }, + albumLink: { + position: 'relative', + display: 'block', + textDecoration: 'none', + }, + albumContainer: {}, + albumPlayButton: { color: 'white' }, + }), + { name: 'NDAlbumGridView' }, +) + +const useCoverStyles = makeStyles({ + cover: { + display: 'inline-block', + width: '100%', + objectFit: 'contain', + height: (props) => props.height, + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, + }, +}) + +const getColsForWidth = (width) => { + if (width === 'xs') return 2 + if (width === 'sm') return 3 + if (width === 'md') return 4 + if (width === 'lg') return 6 + 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 [imageLoading, setImageLoading] = React.useState(true) + const [imageError, setImageError] = React.useState(false) + const [, dragAlbumRef] = useDrag( + () => ({ + type: DraggableTypes.ALBUM, + item: { albumIds: [record.id] }, + options: { dropEffect: 'copy' }, + }), + [record], + ) + + // Reset image state when record changes + React.useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const handleImageLoad = React.useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = React.useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + return ( +
+
+ {record.name} +
+
+ ) +}) + +const AlbumGridTile = ({ showArtist, record, basePath, ...props }) => { + const classes = useStyles() + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'), { + noSsr: true, + }) + if (!record) { + return null + } + const computedClasses = clsx( + classes.albumContainer, + record.missing && classes.missingAlbum, + ) + return ( +
+ + + + ) + } + actionIcon={} + /> + + + + {record.name} + {record.tags && record.tags['albumversion'] && ( + + {record.tags['albumversion']} + + )} + + + {showArtist ? ( + + ) : ( + + )} +
+ ) +} + +const LoadedAlbumGrid = ({ ids, data, basePath, width }) => { + const classes = useStyles() + const { filterValues } = useListContext() + const isArtistView = !!(filterValues && filterValues.artist_id) + return ( +
+ + {ids.map((id) => ( + + + + ))} + +
+ ) +} + +const AlbumGridView = ({ albumListType, loaded, loading, ...props }) => { + const hide = + (loading && albumListType === 'random') || !props.data || !props.ids + return hide ? : +} + +const AlbumGridViewWithWidth = withWidth()(AlbumGridView) + +export default AlbumGridViewWithWidth diff --git a/ui/src/album/AlbumInfo.jsx b/ui/src/album/AlbumInfo.jsx new file mode 100644 index 0000000..e71cd3d --- /dev/null +++ b/ui/src/album/AlbumInfo.jsx @@ -0,0 +1,148 @@ +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 = { + album: , + 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.jsx b/ui/src/album/AlbumList.jsx new file mode 100644 index 0000000..f10f8db --- /dev/null +++ b/ui/src/album/AlbumList.jsx @@ -0,0 +1,253 @@ +import { useSelector } from 'react-redux' +import { Redirect, useLocation } from 'react-router-dom' +import { + AutocompleteArrayInput, + AutocompleteInput, + Filter, + NullableBooleanInput, + NumberInput, + Pagination, + ReferenceArrayInput, + ReferenceInput, + SearchInput, + usePermissions, + useRefresh, + useTranslate, + useVersion, +} 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' +import { humanize } from 'inflection' +import { makeStyles } from '@material-ui/core/styles' + +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 ( + + + ({ name: [searchText] })} + > + + + ({ name: [searchText] })} + > + + + ({ + tag_value: [searchText], + })} + > + + + ({ + tag_value: [searchText], + })} + > + + + ({ + tag_value: [searchText], + })} + > + + + ({ + tag_value: [searchText], + })} + > + + + ({ + tag_value: [searchText], + })} + > + + + + + {config.enableFavourites && ( + } + defaultValue={true} + /> + )} + {isAdmin && } + + ) +} + +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 randomStartingSeed = Math.random().toString() + +const AlbumList = (props) => { + const { width } = props + 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 || localStorage.getItem('defaultView') || defaultAlbumList + 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={<Pagination rowsPerPageOptions={perPageOptions} />} + title={<AlbumListTitle albumListType={albumListType} />} + > + {albumView.grid ? ( + <AlbumGridView albumListType={albumListType} {...props} /> + ) : ( + <AlbumTableView {...props} /> + )} + </List> + <ExpandInfoDialog content={<AlbumInfo />} /> + </> + ) +} + +const AlbumListWithWidth = withWidth()(AlbumList) + +export default AlbumListWithWidth diff --git a/ui/src/album/AlbumListActions.jsx b/ui/src/album/AlbumListActions.jsx new file mode 100644 index 0000000..a4afeee --- /dev/null +++ b/ui/src/album/AlbumListActions.jsx @@ -0,0 +1,120 @@ +import React, { cloneElement } from 'react' +import { + Button, + sanitizeListRestProps, + TopToolbar, + useTranslate, +} from 'react-admin' +import { + ButtonGroup, + useMediaQuery, + Typography, + makeStyles, +} from '@material-ui/core' +import ViewHeadlineIcon from '@material-ui/icons/ViewHeadline' +import ViewModuleIcon from '@material-ui/icons/ViewModule' +import { useDispatch, useSelector } from 'react-redux' +import { albumViewGrid, albumViewTable } from '../actions' +import { ToggleFieldsMenu } from '../common' + +const useStyles = makeStyles({ + title: { margin: '1rem' }, + buttonGroup: { width: '100%', justifyContent: 'center' }, + leftButton: { paddingRight: '0.5rem' }, + rightButton: { paddingLeft: '0.5rem' }, +}) + +const AlbumViewToggler = React.forwardRef( + ({ showTitle = true, disableElevation, fullWidth }, ref) => { + const dispatch = useDispatch() + const albumView = useSelector((state) => state.albumView) + const classes = useStyles() + const translate = useTranslate() + return ( + <div ref={ref}> + {showTitle && ( + <Typography className={classes.title}> + {translate('ra.toggleFieldsMenu.layout')} + </Typography> + )} + <ButtonGroup + variant="text" + color="primary" + aria-label="text primary button group" + className={classes.buttonGroup} + > + <Button + size="small" + className={classes.leftButton} + label={translate('ra.toggleFieldsMenu.grid')} + color={albumView.grid ? 'primary' : 'secondary'} + onClick={() => dispatch(albumViewGrid())} + > + <ViewModuleIcon fontSize="inherit" /> + </Button> + <Button + size="small" + className={classes.rightButton} + label={translate('ra.toggleFieldsMenu.table')} + color={albumView.grid ? 'secondary' : 'primary'} + onClick={() => dispatch(albumViewTable())} + > + <ViewHeadlineIcon fontSize="inherit" /> + </Button> + </ButtonGroup> + </div> + ) + }, +) + +AlbumViewToggler.displayName = 'AlbumViewToggler' + +const AlbumListActions = ({ + currentSort, + className, + resource, + filters, + displayedFilters, + filterValues, + permanentFilter, + exporter, + basePath, + selectedIds, + onUnselectItems, + showFilter, + maxResults, + total, + fullWidth, + ...rest +}) => { + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const albumView = useSelector((state) => state.albumView) + return ( + <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + {filters && + cloneElement(filters, { + resource, + showFilter, + displayedFilters, + filterValues, + context: 'button', + })} + {isNotSmall ? ( + <ToggleFieldsMenu + resource="album" + topbarComponent={AlbumViewToggler} + hideColumns={albumView.grid} + /> + ) : ( + <AlbumViewToggler showTitle={false} /> + )} + </TopToolbar> + ) +} + +AlbumListActions.defaultProps = { + selectedIds: [], + onUnselectItems: () => null, +} + +export default AlbumListActions diff --git a/ui/src/album/AlbumShow.jsx b/ui/src/album/AlbumShow.jsx new file mode 100644 index 0000000..c9e9449 --- /dev/null +++ b/ui/src/album/AlbumShow.jsx @@ -0,0 +1,69 @@ +import React from 'react' +import { + ReferenceManyField, + 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, Title } from '../common' + +const useStyles = makeStyles( + (theme) => ({ + albumActions: { + width: '100%', + }, + }), + { + name: 'NDAlbumShow', + }, +) + +const AlbumShowLayout = (props) => { + const { loading, ...context } = useShowContext(props) + const { record } = context + const classes = useStyles() + useResourceRefresh('album', 'song') + + return ( + <> + {record && <RaTitle title={<Title subTitle={record.name} />} />} + {record && <AlbumDetails {...context} />} + {record && ( + <ReferenceManyField + {...context} + addLabel={false} + reference="song" + target="album_id" + sort={{ field: 'album', order: 'ASC' }} + perPage={0} + pagination={null} + > + <AlbumSongs + resource={'song'} + exporter={false} + album={record} + actions={ + <AlbumActions className={classes.albumActions} record={record} /> + } + /> + </ReferenceManyField> + )} + </> + ) +} + +const AlbumShow = (props) => { + const controllerProps = useShowController(props) + return ( + <ShowContextProvider value={controllerProps}> + <AlbumShowLayout {...props} {...controllerProps} /> + </ShowContextProvider> + ) +} + +export default AlbumShow diff --git a/ui/src/album/AlbumSongs.jsx b/ui/src/album/AlbumSongs.jsx new file mode 100644 index 0000000..d705617 --- /dev/null +++ b/ui/src/album/AlbumSongs.jsx @@ -0,0 +1,219 @@ +import React, { useMemo } from 'react' +import { + BulkActionsToolbar, + FunctionField, + ListToolbar, + NumberField, + TextField, + useListContext, + useVersion, +} from 'react-admin' +import clsx from 'clsx' +import { useDispatch } from 'react-redux' +import { Card, useMediaQuery } from '@material-ui/core' +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, + useSelectedFields, +} from '../common' +import config from '../config' +import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' +import { removeAlbumCommentsFromSongs } from './utils.js' + +const useStyles = makeStyles( + (theme) => ({ + root: {}, + main: { + display: 'flex', + }, + content: { + marginTop: 0, + transition: theme.transitions.create('margin-top'), + position: 'relative', + flex: '1 1 auto', + [theme.breakpoints.down('xs')]: { + boxShadow: 'none', + }, + }, + bulkActionsDisplayed: { + marginTop: -theme.spacing(8), + transition: theme.transitions.create('margin-top'), + }, + actions: { + zIndex: 2, + display: 'flex', + justifyContent: 'flex-end', + flexWrap: 'wrap', + }, + noResults: { padding: 20 }, + columnIcon: { + marginLeft: '3px', + marginTop: '-2px', + verticalAlign: 'text-top', + }, + toolbar: { + justifyContent: 'flex-start', + }, + row: { + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + '& $ratingField': { + visibility: 'visible', + }, + }, + }, + contextMenu: { + visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'), + }, + ratingField: { + visibility: 'hidden', + }, + }), + { name: 'RaList' }, +) + +const AlbumSongs = (props) => { + const { data, ids } = props + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const classes = useStyles({ isDesktop }) + const dispatch = useDispatch() + const version = useVersion() + useResourceRefresh('song', 'album') + + const toggleableFields = useMemo(() => { + return { + trackNumber: isDesktop && ( + <TextField source="trackNumber" label="#" sortable={false} /> + ), + title: ( + <SongTitleField + source="title" + sortable={false} + showTrackNumbers={!isDesktop} + /> + ), + artist: isDesktop && <ArtistLinkField source="artist" sortable={false} />, + duration: <DurationField source="duration" sortable={false} />, + year: isDesktop && ( + <FunctionField + source="year" + render={(r) => r.year || ''} + sortable={false} + /> + ), + playCount: isDesktop && ( + <NumberField source="playCount" sortable={false} /> + ), + playDate: <DateField source="playDate" sortable={false} showTime />, + quality: isDesktop && <QualityInfo source="quality" sortable={false} />, + 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'} + source="rating" + sortable={false} + className={classes.ratingField} + /> + ), + } + }, [isDesktop, classes.ratingField]) + + const columns = useSelectedFields({ + resource: 'albumSong', + columns: toggleableFields, + omittedColumns: ['title'], + defaultOff: [ + 'channels', + 'bpm', + 'year', + 'playCount', + 'playDate', + 'size', + 'mood', + 'genre', + ], + }) + + const bulkActionsLabel = isDesktop + ? 'ra.action.bulk_actions' + : 'ra.action.bulk_actions_mobile' + + return ( + <> + <ListToolbar + classes={{ toolbar: classes.toolbar }} + actions={props.actions} + {...props} + /> + <div className={classes.main}> + <Card + className={clsx(classes.content, { + [classes.bulkActionsDisplayed]: props.selectedIds.length > 0, + })} + key={version} + > + <BulkActionsToolbar {...props} label={bulkActionsLabel}> + <SongBulkActions /> + </BulkActionsToolbar> + <SongDatagrid + rowClick={(id) => dispatch(playTracks(data, ids, id))} + {...props} + hasBulkActions={true} + showDiscSubtitles={true} + contextAlwaysVisible={!isDesktop} + classes={{ row: classes.row }} + > + {columns} + <SongContextMenu + source={'starred'} + sortable={false} + className={classes.contextMenu} + label={ + config.enableFavourites && ( + <FavoriteBorderIcon + fontSize={'small'} + className={classes.columnIcon} + /> + ) + } + /> + </SongDatagrid> + </Card> + </div> + <ExpandInfoDialog content={<SongInfo />} /> + </> + ) +} + +const SanitizedAlbumSongs = (props) => { + removeAlbumCommentsFromSongs(props) + const { loaded, loading, total, ...rest } = useListContext(props) + return <>{loaded && <AlbumSongs {...rest} actions={props.actions} />}</> +} + +export default SanitizedAlbumSongs diff --git a/ui/src/album/AlbumTableView.jsx b/ui/src/album/AlbumTableView.jsx new file mode 100644 index 0000000..1fa33d7 --- /dev/null +++ b/ui/src/album/AlbumTableView.jsx @@ -0,0 +1,190 @@ +import React, { useMemo } from 'react' +import { + Datagrid, + DatagridBody, + DatagridRow, + DateField, + NumberField, + TextField, + FunctionField, +} from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import { makeStyles } from '@material-ui/core/styles' +import { useDrag } from 'react-dnd' +import { + ArtistLinkField, + DurationField, + RangeField, + SimpleList, + AlbumContextMenu, + RatingField, + useSelectedFields, + SizeField, +} from '../common' +import config from '../config' +import { DraggableTypes } from '../consts' +import clsx from 'clsx' + +const useStyles = makeStyles({ + columnIcon: { + marginLeft: '3px', + marginTop: '-2px', + verticalAlign: 'text-top', + }, + row: { + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + '& $ratingField': { + visibility: 'visible', + }, + }, + }, + missingRow: { + opacity: 0.3, + }, + tableCell: { + width: '17.5%', + }, + contextMenu: { + visibility: 'hidden', + }, + ratingField: { + visibility: 'hidden', + }, +}) + +const AlbumDatagridRow = (props) => { + const { record, className } = props + const classes = useStyles() + 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} /> + ) +} + +const AlbumDatagridBody = (props) => ( + <DatagridBody {...props} row={<AlbumDatagridRow />} /> +) + +const AlbumDatagrid = (props) => ( + <Datagrid {...props} body={<AlbumDatagridBody />} /> +) + +const AlbumTableView = ({ + hasShow, + hasEdit, + hasList, + syncWithLocation, + ...rest +}) => { + const classes = useStyles() + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + + const toggleableFields = useMemo(() => { + return { + artist: <ArtistLinkField source="albumArtist" />, + songCount: isDesktop && ( + <NumberField source="songCount" sortByOrder={'DESC'} /> + ), + playCount: isDesktop && ( + <NumberField source="playCount" sortByOrder={'DESC'} /> + ), + 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 && ( + <RatingField + source={'rating'} + resource={'album'} + sortByOrder={'DESC'} + className={classes.ratingField} + /> + ), + createdAt: isDesktop && <DateField source="createdAt" showTime />, + } + }, [classes.ratingField, isDesktop]) + + const columns = useSelectedFields({ + resource: 'album', + columns: toggleableFields, + defaultOff: ['createdAt', 'size', 'mood'], + }) + + return isXsmall ? ( + <SimpleList + primaryText={(r) => r.name} + secondaryText={(r) => ( + <> + {r.albumArtist} + {config.enableStarRating && ( + <> + <br /> + <RatingField + record={r} + sortByOrder={'DESC'} + source={'rating'} + resource={'album'} + size={'small'} + /> + </> + )} + </> + )} + tertiaryText={(r) => ( + <> + <RangeField record={r} source={'year'} sortBy={'max_year'} /> +       + </> + )} + linkType={'show'} + rightIcon={(r) => <AlbumContextMenu record={r} />} + {...rest} + /> + ) : ( + <AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}> + <TextField source="name" /> + {columns} + <AlbumContextMenu + source={'starred_at'} + sortByOrder={'DESC'} + sortable={config.enableFavourites} + className={classes.contextMenu} + label={ + config.enableFavourites && ( + <FavoriteBorderIcon + fontSize={'small'} + className={classes.columnIcon} + /> + ) + } + /> + </AlbumDatagrid> + ) +} + +export default AlbumTableView diff --git a/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap b/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap new file mode 100644 index 0000000..706e50a --- /dev/null +++ b/ui/src/album/__snapshots__/AlbumDetails.test.jsx.snap @@ -0,0 +1,253 @@ +// 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.jsx new file mode 100644 index 0000000..2279182 --- /dev/null +++ b/ui/src/album/albumLists.jsx @@ -0,0 +1,83 @@ +import React from 'react' +import ShuffleIcon from '@material-ui/icons/Shuffle' +import LibraryAddIcon from '@material-ui/icons/LibraryAdd' +import VideoLibraryIcon from '@material-ui/icons/VideoLibrary' +import RepeatIcon from '@material-ui/icons/Repeat' +import AlbumIcon from '@material-ui/icons/Album' +import FavoriteIcon from '@material-ui/icons/Favorite' +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import StarIcon from '@material-ui/icons/Star' +import StarBorderIcon from '@material-ui/icons/StarBorder' +import AlbumOutlinedIcon from '@material-ui/icons/AlbumOutlined' +import LibraryAddOutlinedIcon from '@material-ui/icons/LibraryAddOutlined' +import VideoLibraryOutlinedIcon from '@material-ui/icons/VideoLibraryOutlined' +import config from '../config' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' + +const albumLists = { + all: { + icon: ( + <DynamicMenuIcon + path={'album/all'} + icon={AlbumOutlinedIcon} + activeIcon={AlbumIcon} + /> + ), + params: 'sort=name&order=ASC&filter={}', + }, + random: { + icon: <ShuffleIcon />, + params: 'sort=random&order=ASC&filter={}', + }, + ...(config.enableFavourites && { + starred: { + icon: ( + <DynamicMenuIcon + path={'album/starred'} + icon={FavoriteBorderIcon} + activeIcon={FavoriteIcon} + /> + ), + params: 'sort=starred_at&order=DESC&filter={"starred":true}', + }, + }), + ...(config.enableStarRating && { + topRated: { + icon: ( + <DynamicMenuIcon + path={'album/topRated'} + icon={StarBorderIcon} + activeIcon={StarIcon} + /> + ), + params: 'sort=rating&order=DESC&filter={"has_rating":true}', + }, + }), + recentlyAdded: { + icon: ( + <DynamicMenuIcon + path={'album/recentlyAdded'} + icon={LibraryAddOutlinedIcon} + activeIcon={LibraryAddIcon} + /> + ), + params: 'sort=recently_added&order=DESC&filter={}', + }, + recentlyPlayed: { + icon: ( + <DynamicMenuIcon + path={'album/recentlyPlayed'} + icon={VideoLibraryOutlinedIcon} + activeIcon={VideoLibraryIcon} + /> + ), + params: 'sort=play_date&order=DESC&filter={"recently_played":true}', + }, + mostPlayed: { + icon: <RepeatIcon />, + params: 'sort=play_count&order=DESC&filter={"recently_played":true}', + }, +} + +export default albumLists +export const defaultAlbumList = 'recentlyAdded' diff --git a/ui/src/album/index.jsx b/ui/src/album/index.jsx new file mode 100644 index 0000000..1cdb9d0 --- /dev/null +++ b/ui/src/album/index.jsx @@ -0,0 +1,7 @@ +import AlbumList from './AlbumList' +import AlbumShow from './AlbumShow' + +export default { + list: AlbumList, + show: AlbumShow, +} diff --git a/ui/src/album/utils.js b/ui/src/album/utils.js new file mode 100644 index 0000000..6d03cef --- /dev/null +++ b/ui/src/album/utils.js @@ -0,0 +1,7 @@ +export const removeAlbumCommentsFromSongs = ({ album, data }) => { + if (album?.comment && data) { + Object.values(data).forEach((song) => { + song.comment = '' + }) + } +} diff --git a/ui/src/album/utils.test.js b/ui/src/album/utils.test.js new file mode 100644 index 0000000..2ce56b0 --- /dev/null +++ b/ui/src/album/utils.test.js @@ -0,0 +1,24 @@ +import { removeAlbumCommentsFromSongs } from './utils.js' + +describe('removeAlbumCommentsFromSongs', () => { + const data = { 1: { comment: 'one' }, 2: { comment: 'two' } } + it('does not remove song comments if album does not have comment', () => { + const album = { comment: '' } + removeAlbumCommentsFromSongs({ album, data }) + expect(data['1'].comment).toEqual('one') + expect(data['2'].comment).toEqual('two') + }) + + it('removes song comments if album has comment', () => { + const album = { comment: 'test' } + removeAlbumCommentsFromSongs({ album, data }) + expect(data['1'].comment).toEqual('') + expect(data['2'].comment).toEqual('') + }) + + it('does not crash if album and data arr not available', () => { + expect(() => { + removeAlbumCommentsFromSongs({}) + }).not.toThrow() + }) +}) diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx new file mode 100644 index 0000000..8eebe64 --- /dev/null +++ b/ui/src/artist/ArtistActions.jsx @@ -0,0 +1,148 @@ +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 { IoIosRadio } from 'react-icons/io' +import { playShuffle, playSimilar, playTopSongs } from './actions.js' + +const useStyles = makeStyles((theme) => ({ + toolbar: { + minHeight: 'auto', + padding: '0 !important', + background: 'transparent', + boxShadow: 'none', + '& .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 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]) + + 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} />} + /> + </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 new file mode 100644 index 0000000..a11ee50 --- /dev/null +++ b/ui/src/artist/ArtistActions.test.jsx @@ -0,0 +1,230 @@ +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 { 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 mockNotify = vi.fn() +const mockGetList = vi.fn().mockResolvedValue({ data: [{ id: 's1' }] }) + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useNotify: () => mockNotify, + useDataProvider: () => ({ getList: mockGetList }), + useTranslate: () => (x) => x, + } +}) + +describe('ArtistActions', () => { + const defaultRecord = { id: 'ar1', name: 'Artist' } + + 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(() => {}) + + 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() + }) + }) +}) diff --git a/ui/src/artist/ArtistExternalLink.jsx b/ui/src/artist/ArtistExternalLink.jsx new file mode 100644 index 0000000..1b6d745 --- /dev/null +++ b/ui/src/artist/ArtistExternalLink.jsx @@ -0,0 +1,66 @@ +import React from 'react' +import { useTranslate } from 'react-admin' +import { IconButton, Tooltip, Link } from '@material-ui/core' + +import { ImLastfm2 } from 'react-icons/im' +import MusicBrainz from '../icons/MusicBrainz' +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 linkButtons = [] + const lastFMlink = artistInfo?.biography?.match( + /<a\s+(?:[^>]*?\s+)?href=(["'])(.*?)\1/, + ) + + const addLink = (url, title, icon) => { + const translatedTitle = translate(title) + const link = ( + <Link href={url} target="_blank" rel="noopener noreferrer"> + <Tooltip title={translatedTitle}> + <IconButton size={'small'} aria-label={translatedTitle}> + {icon} + </IconButton> + </Tooltip> + </Link> + ) + const id = linkButtons.length + linkButtons.push(<span key={`link-${record.id}-${id}`}>{link}</span>) + } + + if (config.lastFMEnabled) { + if (lastFMlink) { + addLink( + lastFMlink[2], + 'message.openIn.lastfm', + <ImLastfm2 className="lastfm-icon" />, + ) + } else if (artistInfo?.lastFmUrl) { + addLink( + artistInfo?.lastFmUrl, + 'message.openIn.lastfm', + <ImLastfm2 className="lastfm-icon" />, + ) + } + } + + artistInfo?.musicBrainzId && + addLink( + `https://musicbrainz.org/artist/${artistInfo.musicBrainzId}`, + 'message.openIn.musicbrainz', + <MusicBrainz className="musicbrainz-icon" />, + ) + + return <div className={classes.linkBar}>{intersperse(linkButtons, ' ')}</div> +} + +export default ArtistExternalLinks diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx new file mode 100644 index 0000000..e175763 --- /dev/null +++ b/ui/src/artist/ArtistList.jsx @@ -0,0 +1,224 @@ +import { useMemo } from 'react' +import { useHistory } from 'react-router-dom' +import { + Datagrid, + DatagridBody, + DatagridRow, + Filter, + FunctionField, + NumberField, + 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, + List, + QuickFilter, + useGetHandleArtistClick, + RatingField, + useSelectedFields, + useResourceRefresh, +} 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: { + marginLeft: '3px', + marginTop: '-2px', + verticalAlign: 'text-top', + }, + row: { + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + '& $ratingField': { + visibility: 'visible', + }, + }, + }, + missingRow: { + opacity: 0.3, + }, + contextMenu: { + visibility: 'hidden', + }, + ratingField: { + visibility: 'hidden', + }, +}) + +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 /> + {config.enableFavourites && ( + <QuickFilter + source="starred" + label={<FavoriteIcon fontSize={'small'} />} + defaultValue={true} + /> + )} + {isAdmin && <NullableBooleanInput source="missing" />} + </Filter> + ) +} + +const ArtistDatagridRow = (props) => { + const { record } = props + const [, dragArtistRef] = useDrag( + () => ({ + type: DraggableTypes.ARTIST, + 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} /> + ) +} + +const ArtistDatagridBody = (props) => ( + <DatagridBody {...props} row={<ArtistDatagridRow />} /> +) + +const ArtistDatagrid = (props) => ( + <Datagrid {...props} body={<ArtistDatagridBody />} /> +) + +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( + () => ({ + playCount: <NumberField source="playCount" sortByOrder={'DESC'} />, + rating: config.enableStarRating && ( + <RatingField + source="rating" + sortByOrder={'DESC'} + resource={'artist'} + className={classes.ratingField} + /> + ), + }), + [classes.ratingField], + ) + + const columns = useSelectedFields({ + resource: 'artist', + columns: toggleableFields, + }) + + return isXsmall ? ( + <ArtistSimpleList + linkType={(id) => history.push(handleArtistLink(id))} + {...rest} + /> + ) : ( + <ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> + <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'} + sortByOrder={'DESC'} + sortable={config.enableFavourites} + className={classes.contextMenu} + label={ + config.enableFavourites && ( + <FavoriteBorderIcon + fontSize={'small'} + className={classes.contextHeader} + /> + ) + } + /> + </ArtistDatagrid> + ) +} + +const ArtistList = (props) => { + return ( + <> + <List + {...props} + sort={{ field: 'name', order: 'ASC' }} + exporter={false} + bulkActionButtons={false} + filters={<ArtistFilter />} + filterDefaultValues={{ role: 'albumartist' }} + actions={<ArtistListActions />} + > + <ArtistListView {...props} /> + </List> + </> + ) +} + +const ArtistListWithWidth = withWidth()(ArtistList) + +export default ArtistListWithWidth diff --git a/ui/src/artist/ArtistListActions.jsx b/ui/src/artist/ArtistListActions.jsx new file mode 100644 index 0000000..54fe317 --- /dev/null +++ b/ui/src/artist/ArtistListActions.jsx @@ -0,0 +1,32 @@ +import React, { cloneElement } from 'react' +import { sanitizeListRestProps, TopToolbar } from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { ToggleFieldsMenu } from '../common' + +const ArtistListActions = ({ + className, + filters, + resource, + showFilter, + displayedFilters, + filterValues, + ...rest +}) => { + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + + return ( + <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + {filters && + cloneElement(filters, { + resource, + showFilter, + displayedFilters, + filterValues, + context: 'button', + })} + {isNotSmall && <ToggleFieldsMenu resource="artist" />} + </TopToolbar> + ) +} + +export default ArtistListActions diff --git a/ui/src/artist/ArtistShow.jsx b/ui/src/artist/ArtistShow.jsx new file mode 100644 index 0000000..c6dc832 --- /dev/null +++ b/ui/src/artist/ArtistShow.jsx @@ -0,0 +1,148 @@ +import React, { useState, createElement, 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, 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', + }, +) + +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) => { + // eslint-disable-next-line no-console + console.error('error on artist page', e) + }) + }, [record.id]) + + const component = isDesktop ? DesktopArtistDetails : MobileArtistDetails + return ( + <> + {createElement(component, { + artistInfo, + record, + biography, + })} + </> + ) +} + +const ArtistShowLayout = (props) => { + const showContext = useShowContext(props) + const record = useRecordContext() + const { width } = props + const [, perPageOptions] = useAlbumsPerPage(width) + const classes = useStyles() + useResourceRefresh('artist', 'album') + + 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)), + ) + 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/ArtistSimpleList.jsx b/ui/src/artist/ArtistSimpleList.jsx new file mode 100644 index 0000000..deeb3ed --- /dev/null +++ b/ui/src/artist/ArtistSimpleList.jsx @@ -0,0 +1,93 @@ +import React from 'react' +import PropTypes from 'prop-types' +import List from '@material-ui/core/List' +import ListItem from '@material-ui/core/ListItem' +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, RatingField } from '../common' +import config from '../config' + +const useStyles = makeStyles( + { + listItem: { + padding: '10px', + }, + title: { + paddingRight: '10px', + width: '80%', + }, + rightIcon: { + top: '26px', + }, + }, + { name: 'RaArtistSimpleList' }, +) + +const ArtistSimpleList = ({ + linkType, + className, + classes: classesOverride, + data, + hasBulkActions, + ids, + loading, + selectedIds, + total, + ...rest +}) => { + const classes = useStyles({ classes: classesOverride }) + return ( + (loading || total > 0) && ( + <List className={className} {...sanitizeListRestProps(rest)}> + {ids.map( + (id) => + data[id] && ( + <span key={id} onClick={() => linkType(id)}> + <ListItem className={classes.listItem} button={true}> + <ListItemText + primary={ + <> + <div className={classes.title}>{data[id].name}</div> + {config.enableStarRating && ( + <RatingField + record={data[id]} + source={'rating'} + resource={'artist'} + size={'small'} + /> + )} + </> + } + /> + <ListItemSecondaryAction className={classes.rightIcon}> + <ListItemIcon> + <ArtistContextMenu record={data[id]} /> + </ListItemIcon> + </ListItemSecondaryAction> + </ListItem> + </span> + ), + )} + </List> + ) + ) +} + +ArtistSimpleList.propTypes = { + className: PropTypes.string, + classes: PropTypes.object, + data: PropTypes.object, + hasBulkActions: PropTypes.bool.isRequired, + ids: PropTypes.array, + selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, +} + +ArtistSimpleList.defaultProps = { + hasBulkActions: false, + selectedIds: [], +} + +export default ArtistSimpleList diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx new file mode 100644 index 0000000..bff2c09 --- /dev/null +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -0,0 +1,200 @@ +import React, { useState } from 'react' +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 } from '../common' +import Lightbox from 'react-image-lightbox' +import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' +import AlbumInfo from '../album/AlbumInfo' +import subsonic from '../subsonic' + +const useStyles = makeStyles( + (theme) => ({ + root: { + display: 'flex', + padding: '1em', + }, + details: { + display: 'flex', + flex: '1', + flexDirection: 'column', + }, + biography: { + display: 'inline-block', + marginTop: '1em', + float: 'left', + wordBreak: 'break-word', + cursor: 'pointer', + minHeight: '4.5em', + }, + content: { + flex: '1 0 auto', + }, + cover: { + width: '12rem', + height: '12rem', + borderRadius: '6em', + cursor: 'pointer', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + objectFit: 'cover', + }, + coverLoading: { + opacity: 0.5, + }, + artistImage: { + maxHeight: '12rem', + minHeight: '12rem', + width: '12rem', + minWidth: '12rem', + backgroundColor: 'inherit', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxShadow: 'none', + }, + artistDetail: { + flex: '1', + padding: '3%', + display: 'flex', + minHeight: '10rem', + }, + button: { + marginLeft: '0.9em', + }, + loveButton: { + top: theme.spacing(-0.2), + left: theme.spacing(0.5), + }, + rating: { + marginTop: '5px', + }, + artistName: { + wordBreak: 'break-word', + }, + }), + { name: 'NDDesktopArtistDetails' }, +) + +const DesktopArtistDetails = ({ artistInfo, record, biography }) => { + const [expanded, setExpanded] = useState(false) + const classes = useStyles() + const title = record.name + const [isLightboxOpen, setLightboxOpen] = React.useState(false) + const [imageLoading, setImageLoading] = React.useState(false) + const [imageError, setImageError] = React.useState(false) + + // Reset image state when artist changes + React.useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const handleImageLoad = React.useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = React.useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = React.useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) + + const handleCloseLightbox = React.useCallback( + () => setLightboxOpen(false), + [], + ) + + return ( + <div className={classes.root}> + <Card className={classes.artistDetail}> + <Card className={classes.artistImage}> + {artistInfo && ( + <CardMedia + key={record.id} + component="img" + src={subsonic.getCoverArtUrl(record, 300)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} + title={title} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} + /> + )} + </Card> + <div className={classes.details}> + <CardContent className={classes.content}> + <Typography + component="h5" + variant="h5" + className={classes.artistName} + > + {title} + <LoveButton + className={classes.loveButton} + record={record} + resource={'artist'} + size={'default'} + aria-label="artist context menu" + color="primary" + /> + </Typography> + {config.enableStarRating && ( + <div> + <RatingField + record={record} + resource={'artist'} + size={'small'} + className={classes.rating} + /> + </div> + )} + <Collapse + collapsedHeight={'4.5em'} + in={expanded} + timeout={'auto'} + className={classes.biography} + > + <Typography + variant={'body1'} + onClick={() => setExpanded(!expanded)} + > + <span dangerouslySetInnerHTML={{ __html: biography }} /> + </Typography> + </Collapse> + </CardContent> + <Typography component={'div'} className={classes.button}> + {config.enableExternalServices && ( + <ArtistExternalLinks artistInfo={artistInfo} record={record} /> + )} + </Typography> + </div> + {isLightboxOpen && !imageError && ( + <Lightbox + imagePadding={50} + animationDuration={200} + imageTitle={record.name} + mainSrc={subsonic.getCoverArtUrl(record)} + onCloseRequest={handleCloseLightbox} + /> + )} + </Card> + <ExpandInfoDialog content={<AlbumInfo />} /> + </div> + ) +} + +export default DesktopArtistDetails diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx new file mode 100644 index 0000000..9d0450a --- /dev/null +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -0,0 +1,188 @@ +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 } from '../common' +import Lightbox from 'react-image-lightbox' +import subsonic from '../subsonic' + +const useStyles = makeStyles( + (theme) => ({ + root: { + display: 'flex', + background: ({ img }) => `url(${img})`, + }, + bgContainer: { + display: 'flex', + height: '15rem', + width: '100vw', + padding: 'unset', + backdropFilter: 'blur(1px)', + backgroundPosition: '50% 30%', + background: `linear-gradient(to bottom, rgba(52 52 52 / 72%), rgba(21 21 21))`, + }, + link: { + margin: '1px', + }, + details: { + display: 'flex', + alignItems: 'flex-start', + flexDirection: 'column', + justifyContent: 'center', + marginLeft: '0.5rem', + }, + biography: { + display: 'flex', + marginLeft: '3%', + marginRight: '3%', + marginTop: '-2em', + zIndex: '1', + '& p': { + whiteSpace: ({ expanded }) => (expanded ? 'unset' : 'nowrap'), + overflow: 'hidden', + width: '95vw', + textOverflow: 'ellipsis', + }, + }, + cover: { + width: 151, + boxShadow: '0px 0px 6px 0px #565656', + borderRadius: '5px', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + objectFit: 'cover', + }, + coverLoading: { + opacity: 0.5, + }, + artistImage: { + marginLeft: '1em', + maxHeight: '7rem', + backgroundColor: 'inherit', + marginTop: '4rem', + width: '7rem', + minWidth: '7rem', + display: 'flex', + borderRadius: '5em', + }, + loveButton: { + top: theme.spacing(-0.2), + left: theme.spacing(0.5), + }, + rating: { + marginTop: '5px', + }, + artistName: { + wordBreak: 'break-word', + }, + }), + { name: 'NDMobileArtistDetails' }, +) + +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] = React.useState(false) + const [imageLoading, setImageLoading] = React.useState(false) + const [imageError, setImageError] = React.useState(false) + + // Reset image state when artist changes + React.useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + const handleImageLoad = React.useCallback(() => { + setImageLoading(false) + setImageError(false) + }, []) + + const handleImageError = React.useCallback(() => { + setImageLoading(false) + setImageError(true) + }, []) + + const handleOpenLightbox = React.useCallback(() => { + if (!imageError) { + setLightboxOpen(true) + } + }, [imageError]) + + const handleCloseLightbox = React.useCallback( + () => setLightboxOpen(false), + [], + ) + + return ( + <> + <div className={classes.root}> + <div className={classes.bgContainer}> + <Card className={classes.artistImage}> + {artistInfo && ( + <CardMedia + key={record.id} + component="img" + src={subsonic.getCoverArtUrl(record, 300)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} + title={title} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} + /> + )} + </Card> + <div className={classes.details}> + <Typography + component="h5" + variant="h5" + className={classes.artistName} + > + {title} + <LoveButton + className={classes.loveButton} + record={record} + resource={'artist'} + size={'small'} + aria-label="love" + color="primary" + /> + </Typography> + {config.enableStarRating && ( + <RatingField + record={record} + resource={'artist'} + size={'small'} + className={classes.rating} + /> + )} + </div> + </div> + </div> + <div className={classes.biography}> + <Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}> + <Typography variant={'body1'} onClick={() => setExpanded(!expanded)}> + <span dangerouslySetInnerHTML={{ __html: biography }} /> + </Typography> + </Collapse> + </div> + {isLightboxOpen && !imageError && ( + <Lightbox + imagePadding={50} + animationDuration={200} + imageTitle={record.name} + mainSrc={img} + onCloseRequest={handleCloseLightbox} + /> + )} + </> + ) +} + +export default MobileArtistDetails diff --git a/ui/src/artist/actions.js b/ui/src/artist/actions.js new file mode 100644 index 0000000..6a8fbd9 --- /dev/null +++ b/ui/src/artist/actions.js @@ -0,0 +1,84 @@ +import subsonic from '../subsonic/index.js' +import { playTracks } from '../actions/index.js' + +const mapReplayGain = (song) => { + const { replayGain: rg } = song + if (!rg) { + return song + } + + return { + ...song, + ...(rg.albumGain !== undefined && { rgAlbumGain: rg.albumGain }), + ...(rg.albumPeak !== undefined && { rgAlbumPeak: rg.albumPeak }), + ...(rg.trackGain !== undefined && { rgTrackGain: rg.trackGain }), + ...(rg.trackPeak !== undefined && { rgTrackPeak: rg.trackPeak }), + } +} + +const processSongsForPlayback = (songs) => { + const songData = {} + const ids = [] + songs.forEach((s) => { + const song = mapReplayGain(s) + songData[song.id] = song + ids.push(song.id) + }) + return { songData, ids } +} + +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 playSimilar = async (dispatch, notify, id) => { + const res = await subsonic.getSimilarSongs2(id, 100) + const data = res.json['subsonic-response'] + + if (data.status !== 'ok') { + throw new Error( + `Error fetching similar songs: ${data.error?.message || 'Unknown error'} (Code: ${data.error?.code || 'unknown'})`, + ) + } + + const songs = data.similarSongs2?.song || [] + if (!songs.length) { + notify('message.noSimilarSongsFound', 'warning') + return + } + + const { songData, ids } = processSongsForPlayback(songs) + dispatch(playTracks(songData, ids)) +} + +export const playShuffle = async (dataProvider, dispatch, id) => { + const res = await dataProvider.getList('song', { + pagination: { page: 1, perPage: 500 }, + 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.jsx new file mode 100644 index 0000000..6b20114 --- /dev/null +++ b/ui/src/artist/index.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import ArtistList from './ArtistList' +import ArtistShow from './ArtistShow' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' +import MicNoneOutlinedIcon from '@material-ui/icons/MicNoneOutlined' +import MicIcon from '@material-ui/icons/Mic' + +export default { + list: ArtistList, + show: ArtistShow, + icon: ( + <DynamicMenuIcon + path={'artist'} + icon={MicNoneOutlinedIcon} + activeIcon={MicIcon} + /> + ), +} diff --git a/ui/src/audioplayer/AudioTitle.jsx b/ui/src/audioplayer/AudioTitle.jsx new file mode 100644 index 0000000..093bb53 --- /dev/null +++ b/ui/src/audioplayer/AudioTitle.jsx @@ -0,0 +1,82 @@ +import React from 'react' +import { useMediaQuery } from '@material-ui/core' +import { Link } from 'react-router-dom' +import clsx from 'clsx' +import { QualityInfo } from '../common' +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) { + return '' + } + + const qi = { + suffix: song.suffix, + bitRate: song.bitRate, + rgAlbumGain: song.rgAlbumGain, + rgAlbumPeak: song.rgAlbumPeak, + rgTrackGain: song.rgTrackGain, + rgTrackPeak: song.rgTrackPeak, + } + + 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}> + <span> + <span className={clsx(classes.songTitle, 'songTitle')}>{title}</span> + {isDesktop && ( + <QualityInfo + record={qi} + className={classes.qualityInfo} + {...gainInfo} + /> + )} + </span> + {isMobile ? ( + <> + <span className={classes.songInfo}> + <span className={'songArtist'}>{song.artist}</span> + </span> + <span className={clsx(classes.songInfo, classes.songAlbum)}> + <span className={'songAlbum'}>{song.album}</span> + {song.year ? ` - ${song.year}` : ''} + </span> + </> + ) : ( + <span className={classes.songInfo}> + <span className={'songArtist'}>{song.artist}</span> -{' '} + <span className={'songAlbum'}>{song.album}</span> + {song.year ? ` - ${song.year}` : ''} + </span> + )} + </Link> + ) +}) + +AudioTitle.displayName = 'AudioTitle' + +export default AudioTitle diff --git a/ui/src/audioplayer/AudioTitle.test.jsx b/ui/src/audioplayer/AudioTitle.test.jsx new file mode 100644 index 0000000..7b297c0 --- /dev/null +++ b/ui/src/audioplayer/AudioTitle.test.jsx @@ -0,0 +1,58 @@ +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.jsx b/ui/src/audioplayer/Player.jsx new file mode 100644 index 0000000..03419ad --- /dev/null +++ b/ui/src/audioplayer/Player.jsx @@ -0,0 +1,318 @@ +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, + setPlayMode, + 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' + +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 || {} + + const numericGain = calculateGain(gainInfo, song) + gainNode.gain.setValueAtTime(numericGain, context.currentTime) + } + }, [audioInstance, context, gainNode, playerState, gainInfo]) + + 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.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) { + const pos = startTime === null ? null : Math.floor(info.currentTime) + subsonic.nowPlaying(info.trackId, pos) + } + 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 }) + // eslint-disable-next-line no-console + .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} + onPlayModeChange={(mode) => dispatch(setPlayMode(mode))} + onAudioEnded={onAudioEnded} + onCoverClick={onCoverClick} + onBeforeDestroy={onBeforeDestroy} + getAudioInstance={setAudioInstance} + /> + <GlobalHotKeys handlers={handlers} keyMap={keyMap} allowChanges /> + </ThemeProvider> + ) +} + +export { Player } diff --git a/ui/src/audioplayer/PlayerToolbar.jsx b/ui/src/audioplayer/PlayerToolbar.jsx new file mode 100644 index 0000000..4812141 --- /dev/null +++ b/ui/src/audioplayer/PlayerToolbar.jsx @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..d0368b0 --- /dev/null +++ b/ui/src/audioplayer/PlayerToolbar.test.jsx @@ -0,0 +1,166 @@ +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/index.js b/ui/src/audioplayer/index.js new file mode 100644 index 0000000..91042b6 --- /dev/null +++ b/ui/src/audioplayer/index.js @@ -0,0 +1 @@ +export * from './Player' diff --git a/ui/src/audioplayer/keyHandlers.jsx b/ui/src/audioplayer/keyHandlers.jsx new file mode 100644 index 0000000..793276d --- /dev/null +++ b/ui/src/audioplayer/keyHandlers.jsx @@ -0,0 +1,37 @@ +const keyHandlers = (audioInstance, playerState) => { + const nextSong = () => { + const idx = playerState.queue.findIndex( + (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, + ) + return idx !== null ? playerState.queue[idx - 1] : null + } + + return { + TOGGLE_PLAY: (e) => { + e.preventDefault() + audioInstance && audioInstance.togglePlay() + }, + VOL_UP: () => + (audioInstance.volume = Math.min(1, audioInstance.volume + 0.1)), + VOL_DOWN: () => + (audioInstance.volume = Math.max(0, audioInstance.volume - 0.1)), + PREV_SONG: (e) => { + if (!e.metaKey && prevSong()) audioInstance && audioInstance.playPrev() + }, + CURRENT_SONG: () => { + window.location.href = `#/album/${playerState.current?.song.albumId}/show` + }, + NEXT_SONG: (e) => { + if (!e.metaKey && nextSong()) audioInstance && audioInstance.playNext() + }, + } +} + +export default keyHandlers diff --git a/ui/src/audioplayer/locale.js b/ui/src/audioplayer/locale.js new file mode 100644 index 0000000..4cc052a --- /dev/null +++ b/ui/src/audioplayer/locale.js @@ -0,0 +1,27 @@ +const locale = (translate) => ({ + playListsText: translate('player.playListsText'), + openText: translate('player.openText'), + closeText: translate('player.closeText'), + notContentText: translate('player.notContentText'), + clickToPlayText: translate('player.clickToPlayText'), + clickToPauseText: translate('player.clickToPauseText'), + nextTrackText: translate('player.nextTrackText'), + previousTrackText: translate('player.previousTrackText'), + reloadText: translate('player.reloadText'), + volumeText: translate('player.volumeText'), + toggleLyricText: translate('player.toggleLyricText'), + toggleMiniModeText: translate('player.toggleMiniModeText'), + destroyText: translate('player.destroyText'), + downloadText: translate('player.downloadText'), + removeAudioListsText: translate('player.removeAudioListsText'), + clickToDeleteText: (name) => translate('player.clickToDeleteText', { name }), + emptyLyricText: translate('player.emptyLyricText'), + playModeText: { + order: translate('player.playModeText.order'), + orderLoop: translate('player.playModeText.orderLoop'), + singleLoop: translate('player.playModeText.singleLoop'), + shufflePlay: translate('player.playModeText.shufflePlay'), + }, +}) + +export default locale diff --git a/ui/src/audioplayer/styles.js b/ui/src/audioplayer/styles.js new file mode 100644 index 0000000..30a14d4 --- /dev/null +++ b/ui/src/audioplayer/styles.js @@ -0,0 +1,93 @@ +import { makeStyles } from '@material-ui/core/styles' + +const useStyle = makeStyles( + (theme) => ({ + audioTitle: { + textDecoration: 'none', + color: theme.palette.primary.dark, + }, + songTitle: { + fontWeight: 'bold', + '&:hover + $qualityInfo': { + opacity: 1, + }, + }, + songInfo: { + display: 'block', + marginTop: '2px', + }, + songAlbum: { + fontStyle: 'italic', + fontSize: 'smaller', + }, + qualityInfo: { + marginTop: '-4px', + opacity: 0, + transition: 'all 500ms ease-out', + }, + player: { + display: (props) => (props.visible ? 'block' : 'none'), + '@media screen and (max-width:810px)': { + '& .sound-operation': { + display: 'none', + }, + }, + '@media (prefers-reduced-motion)': { + '& .music-player-panel .panel-content div.img-rotate': { + animation: 'none', + }, + }, + '& .progress-bar-content': { + display: 'flex', + flexDirection: 'column', + }, + '& .play-mode-title': { + 'pointer-events': 'none', + }, + '& .music-player-panel .panel-content div.img-rotate': { + // Customize desktop player when cover animation is disabled + animationDuration: (props) => !props.enableCoverAnimation && '0s', + borderRadius: (props) => !props.enableCoverAnimation && '0', + // Fix cover display when image is not square + backgroundSize: 'contain', + backgroundPosition: 'center', + }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover': + { + // Customize mobile player when cover animation is disabled + borderRadius: (props) => !props.enableCoverAnimation && '0', + width: (props) => !props.enableCoverAnimation && '85%', + maxWidth: (props) => !props.enableCoverAnimation && '600px', + height: (props) => !props.enableCoverAnimation && 'auto', + // Fix cover display when image is not square + aspectRatio: '1/1', + display: 'flex', + }, + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover': + { + animationDuration: (props) => !props.enableCoverAnimation && '0s', + objectFit: 'contain', // Fix cover display when image is not square + }, + // Hide old singer display + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-singer': + { + display: 'none', + }, + // Hide extra whitespace from switch div + '& .react-jinke-music-player-mobile .react-jinke-music-player-mobile-switch': + { + display: 'none', + }, + '& .music-player-panel .panel-content .progress-bar-content section.audio-main': + { + display: (props) => (props.isRadio ? 'none' : 'inline-flex'), + }, + '& .react-jinke-music-player-mobile-progress': { + display: (props) => (props.isRadio ? 'none' : 'flex'), + }, + }, + }), + { name: 'NDAudioPlayer' }, +) + +export default useStyle diff --git a/ui/src/authProvider.js b/ui/src/authProvider.js new file mode 100644 index 0000000..4ae238e --- /dev/null +++ b/ui/src/authProvider.js @@ -0,0 +1,111 @@ +import { jwtDecode } from 'jwt-decode' +import { baseUrl } from './utils' +import config from './config' +import { removeHomeCache } from './utils/removeHomeCache' + +// config sent from server may contain authentication info, for example when the user is authenticated +// by a reverse proxy request header +if (config.auth) { + try { + storeAuthenticationInfo(config.auth) + } catch (e) { + // eslint-disable-next-line no-console + console.log(e) + } +} + +function storeAuthenticationInfo(authInfo) { + authInfo.token && localStorage.setItem('token', authInfo.token) + localStorage.setItem('userId', authInfo.id) + localStorage.setItem('name', authInfo.name) + localStorage.setItem('username', authInfo.username) + authInfo.avatar && localStorage.setItem('avatar', authInfo.avatar) + localStorage.setItem('role', authInfo.isAdmin ? 'admin' : 'regular') + localStorage.setItem('subsonic-salt', authInfo.subsonicSalt) + localStorage.setItem('subsonic-token', authInfo.subsonicToken) + localStorage.setItem('is-authenticated', 'true') +} + +const authProvider = { + login: ({ username, password }) => { + let url = baseUrl('/auth/login') + if (config.firstTime) { + url = baseUrl('/auth/createAdmin') + } + const request = new Request(url, { + method: 'POST', + body: JSON.stringify({ username, password }), + headers: new Headers({ 'Content-Type': 'application/json' }), + }) + return fetch(request) + .then((response) => { + if (response.status < 200 || response.status >= 300) { + throw new Error(response.statusText) + } + return response.json() + }) + .then((response) => { + jwtDecode(response.token) // Validate token + storeAuthenticationInfo(response) + // Avoid "going to create admin" dialog after logout/login without a refresh + config.firstTime = false + removeHomeCache() + return response + }) + .catch((error) => { + if ( + error.message === 'Failed to fetch' || + error.stack === 'TypeError: Failed to fetch' + ) { + throw new Error('errors.network_error') + } + + throw new Error(error) + }) + }, + + logout: () => { + removeItems() + return Promise.resolve() + }, + + checkAuth: () => + localStorage.getItem('is-authenticated') + ? Promise.resolve() + : Promise.reject(), + + checkError: ({ status }) => { + if (status === 401) { + removeItems() + return Promise.reject() + } + return Promise.resolve() + }, + + getPermissions: () => { + const role = localStorage.getItem('role') + return role ? Promise.resolve(role) : Promise.reject() + }, + + getIdentity: () => { + return Promise.resolve({ + id: localStorage.getItem('username'), + fullName: localStorage.getItem('name'), + avatar: localStorage.getItem('avatar'), + }) + }, +} + +const removeItems = () => { + localStorage.removeItem('token') + localStorage.removeItem('userId') + localStorage.removeItem('name') + localStorage.removeItem('username') + localStorage.removeItem('avatar') + localStorage.removeItem('role') + localStorage.removeItem('subsonic-salt') + localStorage.removeItem('subsonic-token') + localStorage.removeItem('is-authenticated') +} + +export default authProvider diff --git a/ui/src/common/AddToPlaylistButton.jsx b/ui/src/common/AddToPlaylistButton.jsx new file mode 100644 index 0000000..9cddc74 --- /dev/null +++ b/ui/src/common/AddToPlaylistButton.jsx @@ -0,0 +1,38 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' +import { Button, useTranslate, useUnselectAll } from 'react-admin' +import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd' +import { openAddToPlaylist } from '../actions' + +export const AddToPlaylistButton = ({ resource, selectedIds, className }) => { + const translate = useTranslate() + const dispatch = useDispatch() + const unselectAll = useUnselectAll() + + const handleClick = () => { + dispatch( + openAddToPlaylist({ + selectedIds, + onSuccess: () => unselectAll(resource), + }), + ) + } + + return ( + <Button + aria-controls="simple-menu" + aria-haspopup="true" + onClick={handleClick} + className={className} + label={translate('resources.song.actions.addToPlaylist')} + > + <PlaylistAddIcon /> + </Button> + ) +} + +AddToPlaylistButton.propTypes = { + resource: PropTypes.string.isRequired, + selectedIds: PropTypes.arrayOf(PropTypes.string).isRequired, +} diff --git a/ui/src/common/ArtistLinkField.jsx b/ui/src/common/ArtistLinkField.jsx new file mode 100644 index 0000000..d41b47b --- /dev/null +++ b/ui/src/common/ArtistLinkField.jsx @@ -0,0 +1,174 @@ +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' + +const ALink = withWidth()((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 new file mode 100644 index 0000000..09fdf64 --- /dev/null +++ b/ui/src/common/ArtistLinkField.test.jsx @@ -0,0 +1,238 @@ +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/common/BatchPlayButton.jsx b/ui/src/common/BatchPlayButton.jsx new file mode 100644 index 0000000..f4c1369 --- /dev/null +++ b/ui/src/common/BatchPlayButton.jsx @@ -0,0 +1,61 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { + Button, + useDataProvider, + useTranslate, + useUnselectAll, + useNotify, +} from 'react-admin' +import { useDispatch } from 'react-redux' + +export const BatchPlayButton = ({ + resource, + selectedIds, + action, + label, + icon, + className, +}) => { + const dispatch = useDispatch() + const translate = useTranslate() + const dataProvider = useDataProvider() + const unselectAll = useUnselectAll() + const notify = useNotify() + + const addToQueue = () => { + dataProvider + .getMany(resource, { ids: selectedIds }) + .then((response) => { + // 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)) + }) + .catch(() => { + notify('ra.page.error', 'warning') + }) + unselectAll(resource) + } + + const caption = translate(label) + return ( + <Button + aria-label={caption} + onClick={addToQueue} + label={caption} + className={className} + > + {icon} + </Button> + ) +} + +BatchPlayButton.propTypes = { + action: PropTypes.func.isRequired, + label: PropTypes.string.isRequired, + icon: PropTypes.object.isRequired, +} diff --git a/ui/src/common/BatchShareButton.jsx b/ui/src/common/BatchShareButton.jsx new file mode 100644 index 0000000..8294953 --- /dev/null +++ b/ui/src/common/BatchShareButton.jsx @@ -0,0 +1,40 @@ +import React from 'react' +import { Button, useTranslate, useUnselectAll } from 'react-admin' +import { useDispatch } from 'react-redux' +import { openShareMenu } from '../actions' +import ShareIcon from '@material-ui/icons/Share' + +export const BatchShareButton = ({ resource, selectedIds, className }) => { + const dispatch = useDispatch() + const translate = useTranslate() + const unselectAll = useUnselectAll() + + const share = () => { + dispatch( + openShareMenu( + selectedIds, + resource, + translate('ra.action.bulk_actions', { + _: 'ra.action.bulk_actions', + smart_count: selectedIds.length, + }), + 'message.shareBatchDialogTitle', + ), + ) + unselectAll(resource) + } + + const caption = translate('ra.action.share') + return ( + <Button + aria-label={caption} + onClick={share} + label={caption} + className={className} + > + <ShareIcon /> + </Button> + ) +} + +BatchShareButton.propTypes = {} diff --git a/ui/src/common/BitrateField.jsx b/ui/src/common/BitrateField.jsx new file mode 100644 index 0000000..8b9552e --- /dev/null +++ b/ui/src/common/BitrateField.jsx @@ -0,0 +1,18 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useRecordContext } from 'react-admin' + +export const BitrateField = ({ source, ...rest }) => { + const record = useRecordContext(rest) + return <span>{`${record[source]} kbps`}</span> +} + +BitrateField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +} + +BitrateField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/common/CollapsibleComment.jsx b/ui/src/common/CollapsibleComment.jsx new file mode 100644 index 0000000..77750e7 --- /dev/null +++ b/ui/src/common/CollapsibleComment.jsx @@ -0,0 +1,64 @@ +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.jsx new file mode 100644 index 0000000..47c9c67 --- /dev/null +++ b/ui/src/common/ContextMenus.jsx @@ -0,0 +1,279 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import { useDispatch } from 'react-redux' +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, useTranslate } from 'react-admin' +import clsx from 'clsx' +import { + playNext, + addTracks, + playTracks, + shuffleTracks, + openAddToPlaylist, + openDownloadMenu, + openExtendedInfoDialog, + DOWNLOAD_MENU_ALBUM, + DOWNLOAD_MENU_ARTIST, + openShareMenu, +} from '../actions' +import { LoveButton } from './LoveButton' +import config from '../config' +import { formatBytes } from '../utils' + +const useStyles = makeStyles({ + noWrap: { + whiteSpace: 'nowrap', + }, + menu: { + color: (props) => props.color, + }, +}) + +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, + record, + color, + className, + songQueryParams, + hideShare, + hideInfo, +}) => { + const classes = useStyles({ color }) + const dataProvider = useDataProvider() + const dispatch = useDispatch() + const translate = useTranslate() + const notify = useNotify() + const [anchorEl, setAnchorEl] = useState(null) + + const options = { + play: { + enabled: true, + needData: true, + label: translate('resources.album.actions.playAll'), + action: (data, ids) => dispatch(playTracks(data, ids)), + }, + playNext: { + enabled: true, + needData: true, + label: translate('resources.album.actions.playNext'), + action: (data, ids) => dispatch(playNext(data, ids)), + }, + addToQueue: { + enabled: true, + needData: true, + label: translate('resources.album.actions.addToQueue'), + action: (data, ids) => dispatch(addTracks(data, ids)), + }, + shuffle: { + enabled: true, + needData: true, + label: translate('resources.album.actions.shuffle'), + action: (data, ids) => dispatch(shuffleTracks(data, ids)), + }, + addToPlaylist: { + enabled: true, + needData: true, + label: translate('resources.album.actions.addToPlaylist'), + action: (data, ids) => dispatch(openAddToPlaylist({ selectedIds: ids })), + }, + ...(!hideShare && { + share: { + enabled: config.enableSharing, + needData: false, + 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, + ), + ) + }, + }, + ...(!hideInfo && { + info: { + enabled: true, + needData: true, + label: translate('resources.album.actions.info'), + action: () => dispatch(openExtendedInfoDialog(record)), + }, + }), + } + + const handleClick = (e) => { + e.preventDefault() + setAnchorEl(e.currentTarget) + e.stopPropagation() + } + + const handleOnClose = (e) => { + e.preventDefault() + setAnchorEl(null) + e.stopPropagation() + } + + 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 } + } + + const handleItemClick = (e) => { + setAnchorEl(null) + const key = e.target.getAttribute('value') + if (options[key].needData) { + dataProvider + .getList('song', songQueryParams) + .then((response) => { + let { data, ids } = extractSongsData(response) + options[key].action(data, ids) + }) + .catch(() => { + notify('ra.page.error', 'warning') + }) + } else { + options[key].action(record) + } + + e.stopPropagation() + } + + 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} + color={color} + /> + <MoreButton + record={record} + onClick={handleClick} + info={options.info} + aria-label="more" + aria-controls="context-menu" + aria-haspopup="true" + className={classes.menu} + /> + <Menu + id="context-menu" + anchorEl={anchorEl} + keepMounted + open={open} + onClose={handleOnClose} + > + {Object.keys(options).map( + (key) => + options[key].enabled && ( + <MenuItem value={key} key={key} onClick={handleItemClick}> + {options[key].label} + </MenuItem> + ), + )} + </Menu> + </span> + ) +} + +export const AlbumContextMenu = (props) => + props.record ? ( + <ContextMenu + {...props} + resource={'album'} + songQueryParams={{ + pagination: { page: 1, perPage: -1 }, + sort: { field: 'album', order: 'ASC' }, + filter: { + album_id: props.record.id, + disc_number: props.discNumber, + missing: false, + }, + }} + /> + ) : null + +AlbumContextMenu.propTypes = { + record: PropTypes.object, + discNumber: PropTypes.number, + color: PropTypes.string, + showLove: PropTypes.bool, +} + +AlbumContextMenu.defaultProps = { + showLove: true, + addLabel: true, +} + +export const ArtistContextMenu = (props) => + props.record ? ( + <ContextMenu + {...props} + hideInfo={true} + resource={'artist'} + songQueryParams={{ + pagination: { page: 1, perPage: 200 }, + sort: { + field: 'album', + order: 'ASC', + }, + filter: { album_artist_id: props.record.id, missing: false }, + }} + /> + ) : null + +ArtistContextMenu.propTypes = { + record: PropTypes.object, + color: PropTypes.string, + showLove: PropTypes.bool, +} + +ArtistContextMenu.defaultProps = { + showLove: true, + addLabel: true, +} diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx new file mode 100644 index 0000000..dce24a2 --- /dev/null +++ b/ui/src/common/DateField.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import { isDateSet } from '../utils/validations' +import { DateField as RADateField } from 'react-admin' + +export const DateField = (props) => { + const { record, source } = props + const value = record?.[source] + if (!isDateSet(value)) return null + return <RADateField {...props} /> +} + +DateField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/common/DocLink.jsx b/ui/src/common/DocLink.jsx new file mode 100644 index 0000000..d647ad1 --- /dev/null +++ b/ui/src/common/DocLink.jsx @@ -0,0 +1,8 @@ +import React from 'react' +import { docsUrl } from '../utils' + +export const DocLink = ({ path, children }) => ( + <a href={docsUrl(path)} target={'_blank'} rel="noopener noreferrer"> + {children} + </a> +) diff --git a/ui/src/common/DurationField.jsx b/ui/src/common/DurationField.jsx new file mode 100644 index 0000000..63fe8b7 --- /dev/null +++ b/ui/src/common/DurationField.jsx @@ -0,0 +1,25 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { formatDuration } from '../utils' +import { useRecordContext } from 'react-admin' + +export const DurationField = ({ source, ...rest }) => { + const record = useRecordContext(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> + } +} + +DurationField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +} + +DurationField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/common/LibrarySelector.jsx b/ui/src/common/LibrarySelector.jsx new file mode 100644 index 0000000..1e89d3e --- /dev/null +++ b/ui/src/common/LibrarySelector.jsx @@ -0,0 +1,221 @@ +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 new file mode 100644 index 0000000..13b6078 --- /dev/null +++ b/ui/src/common/LibrarySelector.test.jsx @@ -0,0 +1,517 @@ +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.jsx new file mode 100644 index 0000000..0a09e0d --- /dev/null +++ b/ui/src/common/Linkify.jsx @@ -0,0 +1,76 @@ +import React, { useCallback, useMemo } from 'react' +import { Link } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import PropTypes from 'prop-types' + +const useStyles = makeStyles( + (theme) => ({ + link: { + textDecoration: 'none', + color: theme.palette.primary.main, + }, + }), + { name: 'RaLink' }, +) + +const Linkify = ({ text, ...rest }) => { + const classes = useStyles() + const linkify = useCallback((text) => { + const urlRegex = + /(\b(https?|ftp|file):\/\/[-A-Z0-9+&@#/%?=~_|!:,.;]*[-A-Z0-9+&@#/%=~_|])/gi + return [...text.matchAll(urlRegex)] + }, []) + + const parse = useCallback(() => { + const matches = linkify(text) + if (matches.length === 0) return text + + const elements = [] + let lastIndex = 0 + matches.forEach((match, index) => { + // Push text located before matched string + if (match.index > lastIndex) { + elements.push(text.substring(lastIndex, match.index)) + } + + const href = match[0] + // Push Link component + elements.push( + <Link + {...rest} + target="_blank" + className={classes.link} + rel="noopener noreferrer" + key={index} + href={href} + > + {href} + </Link>, + ) + + lastIndex = match.index + href.length + }) + + // Push remaining text + if (text.length > lastIndex) { + elements.push( + <span + key={'last-span-key'} + dangerouslySetInnerHTML={{ __html: text.substring(lastIndex) }} + />, + ) + } + + return elements.length === 1 ? elements[0] : elements + }, [linkify, text, rest, classes.link]) + + const parsedText = useMemo(() => parse(), [parse]) + + return <>{parsedText}</> +} + +Linkify.propTypes = { + text: PropTypes.string, +} + +export default React.memo(Linkify) diff --git a/ui/src/common/Linkify.test.jsx b/ui/src/common/Linkify.test.jsx new file mode 100644 index 0000000..cd19ffa --- /dev/null +++ b/ui/src/common/Linkify.test.jsx @@ -0,0 +1,33 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import Linkify from './Linkify' + +const URL = 'http://www.example.com' + +const expectLink = (url) => { + const linkEl = screen.getByRole('link') + expect(linkEl).not.toBeNull() + expect(linkEl?.href).toBe(url) +} + +describe('<Linkify />', () => { + it('should render link', () => { + render(<Linkify text={URL} />) + expectLink(`${URL}/`) + expect(screen.getByText(URL)).toBeInTheDocument() + }) + + it('should render link and text', () => { + render(<Linkify text={`foo ${URL} bar`} />) + expectLink(`${URL}/`) + expect(screen.getByText(/foo/i)).toBeInTheDocument() + expect(screen.getByText(URL)).toBeInTheDocument() + expect(screen.getByText(/bar/i)).toBeInTheDocument() + }) + + it('should render only text', () => { + render(<Linkify text={'foo bar'} />) + expect(screen.queryAllByRole('link')).toHaveLength(0) + expect(screen.getByText(/foo bar/i)).toBeInTheDocument() + }) +}) diff --git a/ui/src/common/List.jsx b/ui/src/common/List.jsx new file mode 100644 index 0000000..f74ab02 --- /dev/null +++ b/ui/src/common/List.jsx @@ -0,0 +1,21 @@ +import React from 'react' +import { List as RAList } from 'react-admin' +import { Pagination } from './Pagination' +import { Title } from './index' + +export const List = (props) => { + const { resource } = props + return ( + <RAList + title={ + <Title + subTitle={`resources.${resource}.name`} + args={{ smart_count: 2 }} + /> + } + perPage={15} + pagination={<Pagination />} + {...props} + /> + ) +} diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx new file mode 100644 index 0000000..c940acf --- /dev/null +++ b/ui/src/common/LoveButton.jsx @@ -0,0 +1,85 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +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 { useToggleLove } from './useToggleLove' +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', + }, +}) + +export const LoveButton = ({ + resource, + color, + visible, + size, + component: Button, + addLabel, + disabled, + ...rest +}) => { + const record = useRecordContext(rest) || {} + const classes = useStyles({ color, visible, loved: record.starred }) + const [toggleLove, loading] = useToggleLove(resource, record) + + const handleToggleLove = useCallback( + (e) => { + e.preventDefault() + toggleLove() + e.stopPropagation() + }, + [toggleLove], + ) + + if (!config.enableFavourites) { + return <></> + } + return ( + <Button + onClick={handleToggleLove} + size={'small'} + disabled={disabled || loading || record.missing} + className={classes.love} + title={ + isDateSet(record.starredAt) + ? new Date(record.starredAt).toLocaleString() + : undefined + } + {...rest} + > + {record.starred ? ( + <FavoriteIcon fontSize={size} /> + ) : ( + <FavoriteBorderIcon fontSize={size} /> + )} + </Button> + ) +} + +LoveButton.propTypes = { + resource: PropTypes.string.isRequired, + record: PropTypes.object, + visible: PropTypes.bool, + color: PropTypes.string, + size: PropTypes.string, + component: PropTypes.object, + disabled: PropTypes.bool, +} + +LoveButton.defaultProps = { + addLabel: true, + visible: true, + size: 'small', + color: 'inherit', + component: IconButton, + disabled: false, +} diff --git a/ui/src/common/MultiLineTextField.jsx b/ui/src/common/MultiLineTextField.jsx new file mode 100644 index 0000000..f2a07ff --- /dev/null +++ b/ui/src/common/MultiLineTextField.jsx @@ -0,0 +1,54 @@ +import React, { memo } from 'react' +import Typography from '@material-ui/core/Typography' +import sanitizeFieldRestProps from './sanitizeFieldRestProps' +import md5 from 'blueimp-md5' +import { useRecordContext } from 'react-admin' + +export const MultiLineTextField = memo( + ({ + className, + emptyText, + source, + firstLine, + maxLines, + addLabel, + ...rest + }) => { + const record = useRecordContext(rest) + const value = record && record[source] + let lines = value ? value.split('\n') : [] + if (maxLines || firstLine) { + lines = lines.slice(firstLine, maxLines) + } + + return ( + <Typography + className={className} + variant="body2" + component="span" + {...sanitizeFieldRestProps(rest)} + > + {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.jsx b/ui/src/common/MultiLineTextField.test.jsx new file mode 100644 index 0000000..8f29166 --- /dev/null +++ b/ui/src/common/MultiLineTextField.test.jsx @@ -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/Pagination.jsx b/ui/src/common/Pagination.jsx new file mode 100644 index 0000000..e17d9e6 --- /dev/null +++ b/ui/src/common/Pagination.jsx @@ -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/ParticipantsInfo.jsx b/ui/src/common/ParticipantsInfo.jsx new file mode 100644 index 0000000..aecf4f1 --- /dev/null +++ b/ui/src/common/ParticipantsInfo.jsx @@ -0,0 +1,54 @@ +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 new file mode 100644 index 0000000..2182287 --- /dev/null +++ b/ui/src/common/PathField.jsx @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..de8b908 --- /dev/null +++ b/ui/src/common/PathField.test.jsx @@ -0,0 +1,86 @@ +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.jsx new file mode 100644 index 0000000..d871723 --- /dev/null +++ b/ui/src/common/PlayButton.jsx @@ -0,0 +1,60 @@ +import React from 'react' +import PropTypes from 'prop-types' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import { IconButton } from '@material-ui/core' +import { useDispatch } from 'react-redux' +import { useDataProvider } from 'react-admin' +import { playTracks } from '../actions' + +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 } + } + const dataProvider = useDataProvider() + const dispatch = useDispatch() + const playAlbum = (record) => { + dataProvider + .getList('song', { + pagination: { page: 1, perPage: -1 }, + sort: { field: 'album', order: 'ASC' }, + filter: { + album_id: record.id, + disc_number: record.discNumber, + }, + }) + .then((response) => { + let { data, ids } = extractSongsData(response) + dispatch(playTracks(data, ids)) + }) + } + + return ( + <IconButton + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + playAlbum(record) + }} + aria-label="play" + className={className} + size={size} + > + <PlayArrowIcon fontSize={size} /> + </IconButton> + ) +} + +PlayButton.propTypes = { + record: PropTypes.object.isRequired, + size: PropTypes.string, + className: PropTypes.string, +} + +PlayButton.defaultProps = { + size: 'small', +} diff --git a/ui/src/common/QualityInfo.jsx b/ui/src/common/QualityInfo.jsx new file mode 100644 index 0000000..171f5e0 --- /dev/null +++ b/ui/src/common/QualityInfo.jsx @@ -0,0 +1,72 @@ +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 }) => { + 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 + } + } + + 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, +} + +QualityInfo.defaultProps = { + record: {}, + size: 'small', + gainMode: 'none', +} diff --git a/ui/src/common/QualityInfo.test.jsx b/ui/src/common/QualityInfo.test.jsx new file mode 100644 index 0000000..ae18747 --- /dev/null +++ b/ui/src/common/QualityInfo.test.jsx @@ -0,0 +1,80 @@ +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() + }) +}) diff --git a/ui/src/common/QuickFilter.jsx b/ui/src/common/QuickFilter.jsx new file mode 100644 index 0000000..79b09b3 --- /dev/null +++ b/ui/src/common/QuickFilter.jsx @@ -0,0 +1,28 @@ +import React from 'react' +import { Chip, makeStyles } from '@material-ui/core' +import { useTranslate } from 'react-admin' +import { humanize, underscore } from 'inflection' + +const useQuickFilterStyles = makeStyles((theme) => ({ + chip: { + marginBottom: theme.spacing(1), + }, +})) + +export const QuickFilter = ({ source, resource, label, defaultValue }) => { + const translate = useTranslate() + const classes = useQuickFilterStyles() + let lbl = label || source + if (typeof lbl === 'string' || lbl instanceof String) { + if (label) { + lbl = translate(lbl, { + _: humanize(underscore(lbl)), + }) + } else { + lbl = translate(`resources.${resource}.fields.${source}`, { + _: humanize(underscore(source)), + }) + } + } + return <Chip className={classes.chip} label={lbl} /> +} diff --git a/ui/src/common/QuickFilter.test.jsx b/ui/src/common/QuickFilter.test.jsx new file mode 100644 index 0000000..36df2aa --- /dev/null +++ b/ui/src/common/QuickFilter.test.jsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { QuickFilter } from './QuickFilter' +import StarIcon from '@material-ui/icons/Star' + +describe('QuickFilter', () => { + afterEach(cleanup) + + it('renders label if provided', () => { + render(<QuickFilter resource={'song'} source={'name'} label={'MyLabel'} />) + expect(screen.getByText('MyLabel')).not.toBeNull() + }) + + it('renders resource translation if label is not provided', () => { + render(<QuickFilter resource={'song'} source={'name'} />) + expect(screen.getByText('resources.song.fields.name')).not.toBeNull() + }) + + it('renders a component label', () => { + render( + <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/RangeField.jsx b/ui/src/common/RangeField.jsx new file mode 100644 index 0000000..99746c6 --- /dev/null +++ b/ui/src/common/RangeField.jsx @@ -0,0 +1,19 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useRecordContext } from 'react-admin' +import { formatRange } from './formatRange' + +export const RangeField = ({ className, source, ...rest }) => { + const record = useRecordContext(rest) + return <span className={className}>{formatRange(record, source)}</span> +} + +RangeField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +} + +RangeField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx new file mode 100644 index 0000000..f92b0d9 --- /dev/null +++ b/ui/src/common/RatingField.jsx @@ -0,0 +1,84 @@ +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 { useRecordContext } from 'react-admin' + +const useStyles = makeStyles({ + rating: { + color: (props) => props.color, + visibility: (props) => (props.visible === false ? 'hidden' : 'inherit'), + }, + show: { + visibility: 'visible !important', + }, + hide: { + visibility: 'hidden', + }, +}) + +export const RatingField = ({ + resource, + visible, + className, + size, + color, + ...rest +}) => { + const record = useRecordContext(rest) || {} + const [rate, rating] = useRating(resource, record) + const classes = useStyles({ color, visible }) + + const stopPropagation = (e) => { + e.stopPropagation() + } + + const handleRating = useCallback( + (e, val) => { + const targetId = record.mediaFileId || record.id + rate(val ?? 0, targetId) + }, + [rate, record.mediaFileId, record.id], + ) + + return ( + <span + onClick={(e) => stopPropagation(e)} + title={ + isDateSet(record.ratedAt) + ? new Date(record.ratedAt).toLocaleString() + : undefined + } + > + <Rating + name={record.mediaFileId || record.id} + className={clsx( + className, + classes.rating, + rating > 0 ? classes.show : classes.hide, + )} + value={rating} + size={size} + disabled={record?.missing} + emptyIcon={<StarBorderIcon fontSize="inherit" />} + onChange={(e, newValue) => handleRating(e, newValue)} + /> + </span> + ) +} +RatingField.propTypes = { + resource: PropTypes.string.isRequired, + record: PropTypes.object, + visible: PropTypes.bool, + size: PropTypes.string, +} + +RatingField.defaultProps = { + visible: true, + size: 'small', + color: 'inherit', +} diff --git a/ui/src/common/SelectLibraryInput.jsx b/ui/src/common/SelectLibraryInput.jsx new file mode 100644 index 0000000..0ac9783 --- /dev/null +++ b/ui/src/common/SelectLibraryInput.jsx @@ -0,0 +1,228 @@ +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 new file mode 100644 index 0000000..8a7e56d --- /dev/null +++ b/ui/src/common/SelectLibraryInput.test.jsx @@ -0,0 +1,458 @@ +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.jsx new file mode 100644 index 0000000..1631e2c --- /dev/null +++ b/ui/src/common/ShuffleAllButton.jsx @@ -0,0 +1,49 @@ +import React from 'react' +import { Button, useDataProvider, useNotify, useTranslate } from 'react-admin' +import { useDispatch } from 'react-redux' +import ShuffleIcon from '@material-ui/icons/Shuffle' +import { playTracks } from '../actions' +import PropTypes from 'prop-types' + +export const ShuffleAllButton = ({ filters }) => { + const translate = useTranslate() + const dataProvider = useDataProvider() + const dispatch = useDispatch() + const notify = useNotify() + filters = { ...filters, missing: false } + + const handleOnClick = () => { + dataProvider + .getList('song', { + pagination: { page: 1, perPage: 500 }, + sort: { field: 'random', order: 'ASC' }, + filter: filters, + }) + .then((res) => { + const data = {} + res.data.forEach((song) => { + data[song.id] = song + }) + dispatch(playTracks(data)) + }) + .catch(() => { + notify('ra.page.error', 'warning') + }) + } + + return ( + <Button + onClick={handleOnClick} + label={translate('resources.song.actions.shuffleAll')} + > + <ShuffleIcon /> + </Button> + ) +} + +ShuffleAllButton.propTypes = { + filters: PropTypes.object, +} +ShuffleAllButton.defaultProps = { + filters: {}, +} diff --git a/ui/src/common/SimpleList.jsx b/ui/src/common/SimpleList.jsx new file mode 100644 index 0000000..b803993 --- /dev/null +++ b/ui/src/common/SimpleList.jsx @@ -0,0 +1,147 @@ +import React from 'react' +import PropTypes from 'prop-types' +import Avatar from '@material-ui/core/Avatar' +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 { Link } from 'react-router-dom' +import { linkToRecord, sanitizeListRestProps } from 'react-admin' + +const useStyles = makeStyles( + { + link: { + textDecoration: 'none', + color: 'inherit', + }, + tertiary: { float: 'right', opacity: 0.541176 }, + }, + { name: 'RaSimpleList' }, +) + +const LinkOrNot = ({ + classes: classesOverride, + linkType, + basePath, + id, + record, + children, +}) => { + const classes = useStyles({ classes: classesOverride }) + return linkType === 'edit' || linkType === true ? ( + <Link to={linkToRecord(basePath, id)} className={classes.link}> + {children} + </Link> + ) : linkType === 'show' ? ( + <Link to={`${linkToRecord(basePath, id)}/show`} className={classes.link}> + {children} + </Link> + ) : typeof linkType === 'function' ? ( + <span onClick={() => linkType(id, basePath, record)}>{children}</span> + ) : ( + <span>{children}</span> + ) +} + +export const SimpleList = ({ + basePath, + className, + classes: classesOverride, + data, + hasBulkActions, + ids, + loading, + leftAvatar, + leftIcon, + linkType, + onToggleItem, + primaryText, + rightAvatar, + rightIcon, + secondaryText, + selectedIds, + tertiaryText, + total, + ...rest +}) => { + const classes = useStyles({ classes: classesOverride }) + return ( + (loading || total > 0) && ( + <List className={className} {...sanitizeListRestProps(rest)}> + {ids.map((id) => ( + <LinkOrNot + linkType={linkType} + basePath={basePath} + id={id} + key={id} + record={data[id]} + > + <ListItem button={!!linkType}> + {leftIcon && ( + <ListItemIcon>{leftIcon(data[id], id)}</ListItemIcon> + )} + {leftAvatar && ( + <ListItemAvatar> + <Avatar>{leftAvatar(data[id], id)}</Avatar> + </ListItemAvatar> + )} + <ListItemText + primary={ + <div> + {primaryText(data[id], id)} + {tertiaryText && ( + <span className={classes.tertiary}> + {tertiaryText(data[id], id)} + </span> + )} + </div> + } + secondary={secondaryText && secondaryText(data[id], id)} + /> + {(rightAvatar || rightIcon) && ( + <ListItemSecondaryAction> + {rightAvatar && <Avatar>{rightAvatar(data[id], id)}</Avatar>} + {rightIcon && ( + <ListItemIcon>{rightIcon(data[id], id)}</ListItemIcon> + )} + </ListItemSecondaryAction> + )} + </ListItem> + </LinkOrNot> + ))} + </List> + ) + ) +} + +SimpleList.propTypes = { + basePath: PropTypes.string, + className: PropTypes.string, + classes: PropTypes.object, + data: PropTypes.object, + hasBulkActions: PropTypes.bool.isRequired, + ids: PropTypes.array, + leftAvatar: PropTypes.func, + leftIcon: PropTypes.func, + linkType: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.bool, + PropTypes.func, + ]).isRequired, + onToggleItem: PropTypes.func, + primaryText: PropTypes.func, + rightAvatar: PropTypes.func, + rightIcon: PropTypes.func, + secondaryText: PropTypes.func, + selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, + tertiaryText: PropTypes.func, +} + +SimpleList.defaultProps = { + linkType: 'edit', + hasBulkActions: false, + selectedIds: [], +} diff --git a/ui/src/common/SizeField.jsx b/ui/src/common/SizeField.jsx new file mode 100644 index 0000000..34e6682 --- /dev/null +++ b/ui/src/common/SizeField.jsx @@ -0,0 +1,32 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { formatBytes } from '../utils' +import { useRecordContext } from 'react-admin' +import { makeStyles } from '@material-ui/core' + +const useStyles = makeStyles((theme) => ({ + root: { + display: 'inline-block', + }, +})) + +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> + ) +} + +SizeField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +} + +SizeField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/common/SongBulkActions.jsx b/ui/src/common/SongBulkActions.jsx new file mode 100644 index 0000000..9210bb0 --- /dev/null +++ b/ui/src/common/SongBulkActions.jsx @@ -0,0 +1,53 @@ +import React, { Fragment, useEffect } from 'react' +import { useUnselectAll } from 'react-admin' +import { addTracks, playNext, playTracks } from '../actions' +import { RiPlayList2Fill, RiPlayListAddFill } from 'react-icons/ri' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import { BatchPlayButton } from './index' +import { AddToPlaylistButton } from './AddToPlaylistButton' +import { makeStyles } from '@material-ui/core/styles' +import { BatchShareButton } from './BatchShareButton' +import config from '../config' + +const useStyles = makeStyles((theme) => ({ + button: { + color: theme.palette.type === 'dark' ? 'white' : undefined, + }, +})) + +export const SongBulkActions = (props) => { + const classes = useStyles() + const unselectAll = useUnselectAll() + useEffect(() => { + unselectAll(props.resource) + }, [unselectAll, props.resource]) + return ( + <Fragment> + <BatchPlayButton + {...props} + action={playTracks} + label={'resources.song.actions.playNow'} + icon={<PlayArrowIcon />} + className={classes.button} + /> + <BatchPlayButton + {...props} + action={playNext} + label={'resources.song.actions.playNext'} + icon={<RiPlayList2Fill />} + className={classes.button} + /> + <BatchPlayButton + {...props} + action={addTracks} + label={'resources.song.actions.addToQueue'} + icon={<RiPlayListAddFill />} + className={classes.button} + /> + {config.enableSharing && ( + <BatchShareButton {...props} className={classes.button} /> + )} + <AddToPlaylistButton {...props} className={classes.button} /> + </Fragment> + ) +} diff --git a/ui/src/common/SongContextMenu.jsx b/ui/src/common/SongContextMenu.jsx new file mode 100644 index 0000000..f8b0bba --- /dev/null +++ b/ui/src/common/SongContextMenu.jsx @@ -0,0 +1,296 @@ +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 { 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 })), + }, + 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 new file mode 100644 index 0000000..a30da85 --- /dev/null +++ b/ui/src/common/SongContextMenu.test.jsx @@ -0,0 +1,107 @@ +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' + +vi.mock('../dataProvider', () => ({ + httpClient: vi.fn(), +})) + +vi.mock('react-redux', () => ({ useDispatch: () => vi.fn() })) + +const getPlaylistsMock = vi.fn() + +vi.mock('react-admin', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + 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' }], + }) + }) + + 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() + }) +}) diff --git a/ui/src/common/SongDatagrid.jsx b/ui/src/common/SongDatagrid.jsx new file mode 100644 index 0000000..3586cf2 --- /dev/null +++ b/ui/src/common/SongDatagrid.jsx @@ -0,0 +1,285 @@ +import React, { isValidElement, useMemo, useCallback, forwardRef } from 'react' +import { useDispatch } from 'react-redux' +import { + Datagrid, + PureDatagridBody, + PureDatagridRow, + useTranslate, +} from 'react-admin' +import { + TableCell, + TableRow, + Typography, + useMediaQuery, +} from '@material-ui/core' +import PropTypes from 'prop-types' +import { makeStyles } from '@material-ui/core/styles' +import AlbumIcon from '@material-ui/icons/Album' +import clsx from 'clsx' +import { useDrag } from 'react-dnd' +import { playTracks } from '../actions' +import { AlbumContextMenu } from '../common' +import { DraggableTypes } from '../consts' +import { formatFullDate } from '../utils' + +const useStyles = makeStyles({ + subtitle: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + verticalAlign: 'middle', + }, + discIcon: { + verticalAlign: 'text-top', + marginRight: '4px', + }, + row: { + cursor: 'pointer', + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + }, + }, + missingRow: { + cursor: 'inherit', + opacity: 0.3, + }, + headerStyle: { + '& thead': { + boxShadow: '0px 3px 3px rgba(0, 0, 0, 0.15)', + }, + '& th': { + fontWeight: 'bold', + padding: '15px', + }, + }, + contextMenu: { + visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'), + }, +}) + +const DiscSubtitleRow = forwardRef( + ({ record, onClick, colSpan, contextAlwaysVisible }, ref) => { + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const classes = useStyles({ isDesktop }) + const handlePlaySubset = (discNumber) => () => { + onClick(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.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} + showLove={false} + className={classes.contextMenu} + hideShare={true} + hideInfo={true} + visible={contextAlwaysVisible} + /> + </TableCell> + </TableRow> + ) + }, +) + +DiscSubtitleRow.displayName = 'DiscSubtitleRow' + +export const SongDatagridRow = ({ + record, + children, + firstTracksOfDiscs, + contextAlwaysVisible, + onClickSubset, + className, + ...rest +}) => { + const classes = useStyles() + const fields = React.Children.toArray(children).filter((c) => + isValidElement(c), + ) + + const [, dragDiscRef] = useDrag( + () => ({ + type: DraggableTypes.DISC, + item: { + discs: [ + { + albumId: record?.albumId, + discNumber: record?.discNumber, + }, + ], + }, + options: { dropEffect: 'copy' }, + }), + [record], + ) + + const [, dragSongRef] = useDrag( + () => ({ + type: DraggableTypes.SONG, + item: { ids: [record?.mediaFileId || record?.id] }, + options: { dropEffect: 'copy' }, + }), + [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 ( + <> + {firstTracksOfDiscs.has(record.id) && ( + <DiscSubtitleRow + ref={dragDiscRef} + record={record} + onClick={onClickSubset} + contextAlwaysVisible={contextAlwaysVisible} + colSpan={childCount + (rest.expand ? 1 : 0)} + /> + )} + <PureDatagridRow + ref={dragSongRef} + record={record} + {...rest} + rowClick={rowClick} + className={computedClasses} + > + {fields} + </PureDatagridRow> + </> + ) +} + +SongDatagridRow.propTypes = { + record: PropTypes.object, + children: PropTypes.node, + firstTracksOfDiscs: PropTypes.instanceOf(Set), + contextAlwaysVisible: PropTypes.bool, + onClickSubset: PropTypes.func, +} + +SongDatagridRow.defaultProps = { + onClickSubset: () => {}, +} + +const SongDatagridBody = ({ + contextAlwaysVisible, + showDiscSubtitles, + ...rest +}) => { + const dispatch = useDispatch() + const { ids, data } = rest + + const playSubset = useCallback( + (discNumber) => { + let idsToPlay = [] + if (discNumber !== undefined) { + idsToPlay = ids.filter((id) => data[id].discNumber === discNumber) + } + dispatch( + playTracks( + data, + idsToPlay?.filter((id) => !data[id].missing), + ), + ) + }, + [dispatch, data, ids], + ) + + const firstTracksOfDiscs = useMemo(() => { + if (!ids) { + return new Set() + } + let foundSubtitle = false + const set = new Set( + ids + .filter((i) => data[i]) + .reduce((acc, id) => { + const last = acc && acc[acc.length - 1] + foundSubtitle = foundSubtitle || data[id].discSubtitle + if ( + acc.length === 0 || + (last && data[id].discNumber !== data[last].discNumber) + ) { + acc.push(id) + } + return acc + }, []), + ) + if (!showDiscSubtitles || (set.size < 2 && !foundSubtitle)) { + set.clear() + } + return set + }, [ids, data, showDiscSubtitles]) + + return ( + <PureDatagridBody + {...rest} + row={ + <SongDatagridRow + firstTracksOfDiscs={firstTracksOfDiscs} + contextAlwaysVisible={contextAlwaysVisible} + onClickSubset={playSubset} + /> + } + /> + ) +} + +export const SongDatagrid = ({ + contextAlwaysVisible, + showDiscSubtitles, + ...rest +}) => { + const classes = useStyles() + return ( + <Datagrid + className={classes.headerStyle} + isRowSelectable={(r) => !r?.missing} + {...rest} + body={ + <SongDatagridBody + contextAlwaysVisible={contextAlwaysVisible} + showDiscSubtitles={showDiscSubtitles} + /> + } + /> + ) +} + +SongDatagrid.propTypes = { + contextAlwaysVisible: PropTypes.bool, + showDiscSubtitles: PropTypes.bool, + classes: PropTypes.object, +} diff --git a/ui/src/common/SongInfo.jsx b/ui/src/common/SongInfo.jsx new file mode 100644 index 0000000..1b1a014 --- /dev/null +++ b/ui/src/common/SongInfo.jsx @@ -0,0 +1,218 @@ +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.jsx new file mode 100644 index 0000000..d398d34 --- /dev/null +++ b/ui/src/common/SongSimpleList.jsx @@ -0,0 +1,132 @@ +import React from 'react' +import PropTypes from 'prop-types' +import List from '@material-ui/core/List' +import ListItem from '@material-ui/core/ListItem' +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 { DurationField, SongContextMenu, RatingField } from './index' +import { setTrack } from '../actions' +import { useDispatch } from 'react-redux' +import config from '../config' + +const useStyles = makeStyles( + { + link: { + textDecoration: 'none', + color: 'inherit', + }, + listItem: { + padding: '10px', + }, + title: { + paddingRight: '10px', + width: '80%', + }, + secondary: { + marginTop: '-3px', + width: '96%', + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + }, + artist: { + paddingRight: '30px', + }, + timeStamp: { + float: 'right', + color: '#fff', + fontWeight: '200', + opacity: 0.6, + fontSize: '12px', + padding: '2px', + }, + rightIcon: { + top: '26px', + }, + }, + { name: 'RaSongSimpleList' }, +) + +export const SongSimpleList = ({ + basePath, + className, + classes: classesOverride, + data, + hasBulkActions, + ids, + loading, + onToggleItem, + selectedIds, + total, + ...rest +}) => { + const dispatch = useDispatch() + const classes = useStyles({ classes: classesOverride }) + return ( + (loading || total > 0) && ( + <List className={className} {...sanitizeListRestProps(rest)}> + {ids.map( + (id) => + data[id] && ( + <span key={id} onClick={() => dispatch(setTrack(data[id]))}> + <ListItem className={classes.listItem} button={true}> + <ListItemText + primary={ + <div className={classes.title}>{data[id].title}</div> + } + secondary={ + <> + <span className={classes.secondary}> + <span className={classes.artist}> + {data[id].artist} + </span> + <span className={classes.timeStamp}> + <DurationField + record={data[id]} + source={'duration'} + /> + </span> + </span> + {config.enableStarRating && ( + <RatingField + record={data[id]} + source={'rating'} + resource={'song'} + size={'small'} + /> + )} + </> + } + /> + <ListItemSecondaryAction className={classes.rightIcon}> + <ListItemIcon> + <SongContextMenu record={data[id]} visible={true} /> + </ListItemIcon> + </ListItemSecondaryAction> + </ListItem> + </span> + ), + )} + </List> + ) + ) +} + +SongSimpleList.propTypes = { + basePath: PropTypes.string, + className: PropTypes.string, + classes: PropTypes.object, + data: PropTypes.object, + hasBulkActions: PropTypes.bool.isRequired, + ids: PropTypes.array, + onToggleItem: PropTypes.func, + selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, +} + +SongSimpleList.defaultProps = { + hasBulkActions: false, + selectedIds: [], +} diff --git a/ui/src/common/SongTitleField.jsx b/ui/src/common/SongTitleField.jsx new file mode 100644 index 0000000..22c3e40 --- /dev/null +++ b/ui/src/common/SongTitleField.jsx @@ -0,0 +1,94 @@ +import { makeStyles } from '@material-ui/core/styles' +import React from 'react' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import { FunctionField } from 'react-admin' +import { useTheme } from '@material-ui/core/styles' +import PlayingLight from '../icons/playing-light.gif' +import PlayingDark from '../icons/playing-dark.gif' +import PausedLight from '../icons/paused-light.png' +import PausedDark from '../icons/paused-dark.png' + +const useStyles = makeStyles({ + icon: { + width: '32px', + height: '32px', + verticalAlign: 'text-top', + marginLeft: '-8px', + marginTop: '-7px', + paddingRight: '3px', + }, + text: { + verticalAlign: 'text-top', + }, + subtitle: { + opacity: 0.5, + }, +}) + +export const SongTitleField = ({ showTrackNumbers, ...props }) => { + const theme = useTheme() + const classes = useStyles() + const { record } = props + const currentTrack = useSelector((state) => state?.player?.current || {}) + const currentId = currentTrack.trackId + const paused = currentTrack.paused + 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 + } + + const Icon = () => { + let icon + if (paused) { + icon = theme.palette.type === 'light' ? PausedLight : PausedDark + } else { + icon = theme.palette.type === 'light' ? PlayingLight : PlayingDark + } + return ( + <img + src={icon} + className={classes.icon} + alt={paused ? 'paused' : 'playing'} + /> + ) + } + + return ( + <> + {isCurrent && <Icon />} + <FunctionField + {...props} + source="title" + render={trackName} + className={classes.text} + /> + </> + ) +} + +SongTitleField.propTypes = { + record: PropTypes.object, + showTrackNumbers: PropTypes.bool, +} + +SongTitleField.defaultProps = { + record: {}, + showTrackNumbers: false, +} diff --git a/ui/src/common/Title.jsx b/ui/src/common/Title.jsx new file mode 100644 index 0000000..63eabcb --- /dev/null +++ b/ui/src/common/Title.jsx @@ -0,0 +1,14 @@ +import React from 'react' +import { useMediaQuery } from '@material-ui/core' +import { useTranslate } from 'react-admin' + +export const Title = ({ subTitle, args }) => { + const translate = useTranslate() + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const text = translate(subTitle, { ...args, _: subTitle }) + + if (isDesktop) { + return <span>Navidrome {text ? ` - ${text}` : ''}</span> + } + return <span>{text ? text : 'Navidrome'}</span> +} diff --git a/ui/src/common/ToggleFieldsMenu.jsx b/ui/src/common/ToggleFieldsMenu.jsx new file mode 100644 index 0000000..32ae51d --- /dev/null +++ b/ui/src/common/ToggleFieldsMenu.jsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react' +import PropTypes from 'prop-types' +import IconButton from '@material-ui/core/IconButton' +import Menu from '@material-ui/core/Menu' +import MenuItem from '@material-ui/core/MenuItem' +import { makeStyles, Typography } from '@material-ui/core' +import MoreVertIcon from '@material-ui/icons/MoreVert' +import Checkbox from '@material-ui/core/Checkbox' +import { useDispatch, useSelector } from 'react-redux' +import { useTranslate } from 'react-admin' +import { setToggleableFields } from '../actions' + +const useStyles = makeStyles({ + menuIcon: { + position: 'relative', + top: '-0.5em', + }, + menu: { + width: '24ch', + }, + columns: { + maxHeight: '21rem', + overflow: 'auto', + }, + title: { + margin: '1rem', + }, +}) + +export const ToggleFieldsMenu = ({ + resource, + topbarComponent: TopBarComponent, + hideColumns, +}) => { + const [anchorEl, setAnchorEl] = useState(null) + const dispatch = useDispatch() + const translate = useTranslate() + const toggleableColumns = useSelector( + (state) => state.settings.toggleableFields[resource], + ) + const omittedColumns = + useSelector((state) => state.settings.omittedFields[resource]) || [] + + const classes = useStyles() + const open = Boolean(anchorEl) + + const handleOpen = (event) => { + setAnchorEl(event.currentTarget) + } + const handleClose = () => { + setAnchorEl(null) + } + + const handleClick = (selectedColumn) => { + dispatch( + setToggleableFields({ + [resource]: { + ...toggleableColumns, + [selectedColumn]: !toggleableColumns[selectedColumn], + }, + }), + ) + } + + return ( + <div className={classes.menuIcon}> + <IconButton + aria-label="more" + aria-controls="long-menu" + aria-haspopup="true" + onClick={handleOpen} + > + <MoreVertIcon /> + </IconButton> + <Menu + id="long-menu" + anchorEl={anchorEl} + keepMounted + open={open} + onClose={handleClose} + classes={{ + paper: classes.menu, + }} + > + {TopBarComponent && <TopBarComponent />} + {!hideColumns && toggleableColumns ? ( + <div> + <Typography className={classes.title}> + {translate('ra.toggleFieldsMenu.columnsToDisplay')} + </Typography> + <div className={classes.columns}> + {Object.entries(toggleableColumns).map(([key, val]) => + !omittedColumns.includes(key) ? ( + <MenuItem key={key} onClick={() => handleClick(key)}> + <Checkbox checked={val} /> + {translate(`resources.${resource}.fields.${key}`)} + </MenuItem> + ) : null, + )} + </div> + </div> + ) : null} + </Menu> + </div> + ) +} + +ToggleFieldsMenu.propTypes = { + resource: PropTypes.string.isRequired, + topbarComponent: PropTypes.elementType, + hideColumns: PropTypes.bool, +} diff --git a/ui/src/common/Writable.jsx b/ui/src/common/Writable.jsx new file mode 100644 index 0000000..49c1920 --- /dev/null +++ b/ui/src/common/Writable.jsx @@ -0,0 +1,12 @@ +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/formatRange.js b/ui/src/common/formatRange.js new file mode 100644 index 0000000..22e413f --- /dev/null +++ b/ui/src/common/formatRange.js @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..f64d4fe --- /dev/null +++ b/ui/src/common/index.js @@ -0,0 +1,43 @@ +export * from './AddToPlaylistButton' +export * from './ArtistLinkField' +export * from './BatchPlayButton' +export * from './BitrateField' +export * from './CollapsibleComment' +export * from './ContextMenus' +export * from './DateField' +export * from './DocLink' +export * from './DurationField' +export * from './List' +export * from './MultiLineTextField' +export * from './Pagination' +export * from './PlayButton' +export * from './QuickFilter' +export * from './RangeField' +export * from './ShuffleAllButton' +export * from './SimpleList' +export * from './SizeField' +export * from './SongContextMenu' +export * from './SongDatagrid' +export * from './SongInfo' +export * from './SongTitleField' +export * from './LoveButton' +export * from './Title' +export * from './SongBulkActions' +export * from './useAlbumsPerPage' +export * from './useGetHandleArtistClick' +export * from './useInterval' +export * from './useResourceRefresh' +export * from './useRefreshOnEvents' +export * from './useToggleLove' +export * from './useTraceUpdate' +export * from './Writable' +export * from './SongSimpleList' +export * from './RatingField' +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' diff --git a/ui/src/common/playlistUtils.js b/ui/src/common/playlistUtils.js new file mode 100644 index 0000000..74a01d4 --- /dev/null +++ b/ui/src/common/playlistUtils.js @@ -0,0 +1,15 @@ +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) diff --git a/ui/src/common/playlistUtils.test.js b/ui/src/common/playlistUtils.test.js new file mode 100644 index 0000000..2c671ec --- /dev/null +++ b/ui/src/common/playlistUtils.test.js @@ -0,0 +1,78 @@ +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) + }) + }) +}) diff --git a/ui/src/common/sanitizeFieldRestProps.jsx b/ui/src/common/sanitizeFieldRestProps.jsx new file mode 100644 index 0000000..c3a1edf --- /dev/null +++ b/ui/src/common/sanitizeFieldRestProps.jsx @@ -0,0 +1,26 @@ +const sanitizeFieldRestProps = ({ + addLabel, + allowEmpty, + basePath, + cellClassName, + className, + emptyText, + formClassName, + fullWidth, + headerClassName, + label, + linkType, + link, + locale, + record, + resource, + sortable, + sortBy, + sortByOrder, + source, + textAlign, + translateChoice, + ...props +}) => props + +export default sanitizeFieldRestProps diff --git a/ui/src/common/useAlbumsPerPage.jsx b/ui/src/common/useAlbumsPerPage.jsx new file mode 100644 index 0000000..6a02bde --- /dev/null +++ b/ui/src/common/useAlbumsPerPage.jsx @@ -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/useGetHandleArtistClick.jsx b/ui/src/common/useGetHandleArtistClick.jsx new file mode 100644 index 0000000..0fcf2ee --- /dev/null +++ b/ui/src/common/useGetHandleArtistClick.jsx @@ -0,0 +1,11 @@ +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/useInterval.jsx b/ui/src/common/useInterval.jsx new file mode 100644 index 0000000..4ad0caa --- /dev/null +++ b/ui/src/common/useInterval.jsx @@ -0,0 +1,23 @@ +// From https://overreacted.io/making-setinterval-declarative-with-react-hooks/ + +import { useEffect, useRef } from 'react' + +export const useInterval = (callback, delay) => { + const savedCallback = useRef() + + // Remember the latest callback. + useEffect(() => { + savedCallback.current = callback + }, [callback]) + + // Set up the interval. + useEffect(() => { + function tick() { + savedCallback.current() + } + if (delay !== null) { + let id = setInterval(tick, delay) + return () => clearInterval(id) + } + }, [delay]) +} diff --git a/ui/src/common/useLibrarySelection.js b/ui/src/common/useLibrarySelection.js new file mode 100644 index 0000000..c5d84a6 --- /dev/null +++ b/ui/src/common/useLibrarySelection.js @@ -0,0 +1,44 @@ +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 new file mode 100644 index 0000000..30f109d --- /dev/null +++ b/ui/src/common/useLibrarySelection.test.js @@ -0,0 +1,204 @@ +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.jsx b/ui/src/common/useRating.jsx new file mode 100644 index 0000000..2eb5d9e --- /dev/null +++ b/ui/src/common/useRating.jsx @@ -0,0 +1,73 @@ +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(() => { + // For playlist tracks, refresh both resources to keep data in sync + if (record.mediaFileId) { + // This is a playlist track - refresh both the playlist track and the song + const promises = [ + dataProvider.getOne('song', { id: record.mediaFileId }), + dataProvider.getOne('playlistTrack', { + id: record.id, + filter: { playlist_id: record.playlistId }, + }), + ] + + Promise.all(promises) + .catch((e) => { + // eslint-disable-next-line no-console + console.log('Error encountered: ' + e) + }) + .finally(() => { + 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, record.playlistId, 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 new file mode 100644 index 0000000..b135351 --- /dev/null +++ b/ui/src/common/useRating.test.js @@ -0,0 +1,165 @@ +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' + +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()), + } +}) + +describe('useRating', () => { + let getOne + beforeEach(() => { + getOne = vi.fn(() => Promise.resolve()) + 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 both playlist track and song for playlist tracks', async () => { + const record = { + id: 'pt-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) + + // Should refresh both the playlist track and the song + expect(getOne).toHaveBeenCalledTimes(2) + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-1', + filter: { playlist_id: 'pl-1' }, + }) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('includes playlist_id filter when refreshing playlist tracks', async () => { + const record = { + id: 'pt-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') + }) + + // Should rate using the media file ID + expect(subsonic.setRating).toHaveBeenCalledWith('sg-10', 3) + + // Should refresh playlist track with correct playlist_id filter + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-5', + filter: { playlist_id: 'pl-123' }, + }) + // Should also refresh the underlying song + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + }) + + 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) + expect(getOne).toHaveBeenCalledTimes(1) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + 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/useRefreshOnEvents.jsx b/ui/src/common/useRefreshOnEvents.jsx new file mode 100644 index 0000000..b5f1b1e --- /dev/null +++ b/ui/src/common/useRefreshOnEvents.jsx @@ -0,0 +1,109 @@ +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 new file mode 100644 index 0000000..2306cd3 --- /dev/null +++ b/ui/src/common/useRefreshOnEvents.test.js @@ -0,0 +1,233 @@ +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.jsx b/ui/src/common/useResourceRefresh.jsx new file mode 100644 index 0000000..eabff6f --- /dev/null +++ b/ui/src/common/useResourceRefresh.jsx @@ -0,0 +1,97 @@ +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'] } + */ +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.test.js b/ui/src/common/useResourceRefresh.test.js new file mode 100644 index 0000000..eabd478 --- /dev/null +++ b/ui/src/common/useResourceRefresh.test.js @@ -0,0 +1,142 @@ +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(), + } +}) + +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux') + return { + ...actual, + useSelector: vi.fn(), + } +}) + +vi.mock('react-admin', async () => { + const actual = await vi.importActual('react-admin') + return { + ...actual, + useRefresh: vi.fn(), + useDataProvider: vi.fn(), + } +}) + +describe('useResourceRefresh', () => { + const setState = vi.fn() + const useStateMock = (initState) => [initState, setState] + const refresh = vi.fn() + const useRefreshMock = () => refresh + const getMany = vi.fn() + const useDataProviderMock = () => ({ getMany }) + let lastTime + + beforeEach(() => { + vi.spyOn(React, 'useState').mockImplementation(useStateMock) + vi.spyOn(RA, 'useRefresh').mockImplementation(useRefreshMock) + vi.spyOn(RA, 'useDataProvider').mockImplementation(useDataProviderMock) + lastTime = new Date(new Date().valueOf() + 1000) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('stores last time checked, to avoid redundant runs', () => { + const useSelectorMock = () => ({ lastReceived: lastTime }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useResourceRefresh() + + 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) + + useResourceRefresh() + + expect(setState).not.toHaveBeenCalled() + }) + + describe('No visible resources specified', () => { + it('triggers a UI refresh when received a "any" resource refresh', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useResourceRefresh() + + expect(refresh).toHaveBeenCalledTimes(1) + expect(getMany).not.toHaveBeenCalled() + }) + + it('triggers a UI refresh when received an "any" id', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['*'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useResourceRefresh() + + expect(refresh).toHaveBeenCalledTimes(1) + expect(getMany).not.toHaveBeenCalled() + }) + + it('triggers a refetch of the resources received', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { album: ['al-1', 'al-2'], song: ['sg-1', 'sg-2'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useResourceRefresh() + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledTimes(2) + expect(getMany).toHaveBeenCalledWith('album', { ids: ['al-1', 'al-2'] }) + expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1', 'sg-2'] }) + }) + }) + + describe('Visible resources specified', () => { + it('triggers a UI refresh when received a "any" resource refresh', () => { + const useSelectorMock = () => ({ + lastReceived: lastTime, + resources: { '*': '*' }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useResourceRefresh('album') + + expect(refresh).toHaveBeenCalledTimes(1) + expect(getMany).not.toHaveBeenCalled() + }) + + 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'] }, + }) + vi.spyOn(Redux, 'useSelector').mockImplementation(useSelectorMock) + + useResourceRefresh('song') + + expect(refresh).not.toHaveBeenCalled() + expect(getMany).toHaveBeenCalledTimes(1) + expect(getMany).toHaveBeenCalledWith('song', { ids: ['sg-1', 'sg-2'] }) + }) + }) +}) diff --git a/ui/src/common/useSelectedFields.jsx b/ui/src/common/useSelectedFields.jsx new file mode 100644 index 0000000..0560de5 --- /dev/null +++ b/ui/src/common/useSelectedFields.jsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from 'react' +import PropTypes from 'prop-types' +import { useDispatch, useSelector } from 'react-redux' +import { setOmittedFields, setToggleableFields } from '../actions' + +// TODO Refactor +export const useSelectedFields = ({ + resource, + columns, + omittedColumns = [], + defaultOff = [], +}) => { + const dispatch = useDispatch() + const resourceFields = useSelector( + (state) => state.settings.toggleableFields, + )?.[resource] + const omittedFields = useSelector((state) => state.settings.omittedFields)?.[ + resource + ] + + const [filteredComponents, setFilteredComponents] = useState([]) + + useEffect(() => { + if ( + !resourceFields || + Object.keys(resourceFields).length !== Object.keys(columns).length || + !Object.keys(columns).every((c) => c in resourceFields) + ) { + const obj = {} + for (const key of Object.keys(columns)) { + obj[key] = !defaultOff.includes(key) + } + dispatch(setToggleableFields({ [resource]: obj })) + } + if (!omittedFields) { + dispatch(setOmittedFields({ [resource]: omittedColumns })) + } + }, [ + columns, + defaultOff, + dispatch, + omittedColumns, + omittedFields, + resource, + resourceFields, + ]) + + useEffect(() => { + if (resourceFields) { + const filtered = [] + const omitted = omittedColumns + for (const [key, val] of Object.entries(columns)) { + if (!val) omitted.push(key) + else if (resourceFields[key]) filtered.push(val) + } + if (filteredComponents.length !== filtered.length) + setFilteredComponents(filtered) + if (omittedFields.length !== omitted.length) + dispatch(setOmittedFields({ [resource]: omitted })) + } + }, [ + resourceFields, + columns, + dispatch, + omittedColumns, + omittedFields, + resource, + filteredComponents.length, + ]) + + return React.Children.toArray(filteredComponents) +} + +useSelectedFields.propTypes = { + resource: PropTypes.string, + columns: PropTypes.object, + omittedColumns: PropTypes.arrayOf(PropTypes.string), + defaultOff: PropTypes.arrayOf(PropTypes.string), +} + +export const useSetToggleableFields = ( + resource, + toggleableColumns, + defaultOff = [], +) => { + const current = useSelector((state) => state.settings.toggleableFields)?.album + const dispatch = useDispatch() + useEffect(() => { + if (!current) { + dispatch( + setToggleableFields({ + [resource]: toggleableColumns.reduce((acc, cur) => { + return { + ...acc, + ...{ [cur]: true }, + } + }, {}), + }), + ) + dispatch(setOmittedFields({ [resource]: defaultOff })) + } + }, [resource, toggleableColumns, dispatch, current, defaultOff]) +} + +useSetToggleableFields.propTypes = { + resource: PropTypes.string, + toggleableColumns: PropTypes.arrayOf(PropTypes.string), + defaultOff: PropTypes.arrayOf(PropTypes.string), +} diff --git a/ui/src/common/useToggleLove.jsx b/ui/src/common/useToggleLove.jsx new file mode 100644 index 0000000..3f98a2e --- /dev/null +++ b/ui/src/common/useToggleLove.jsx @@ -0,0 +1,64 @@ +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(() => { + const promises = [] + + // Always refresh the original resource + const params = { id: record.id } + if (record.playlistId) { + params.filter = { playlist_id: record.playlistId } + } + promises.push(dataProvider.getOne(resource, params)) + + // If we have a mediaFileId, also refresh the song + if (record.mediaFileId) { + promises.push(dataProvider.getOne('song', { id: record.mediaFileId })) + } + + Promise.all(promises) + .catch((e) => { + // eslint-disable-next-line no-console + console.log('Error encountered: ' + e) + }) + .finally(() => { + if (mountedRef.current) { + setLoading(false) + } + }) + }, [dataProvider, record.mediaFileId, record.id, record.playlistId, 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 new file mode 100644 index 0000000..640e9ff --- /dev/null +++ b/ui/src/common/useToggleLove.test.js @@ -0,0 +1,136 @@ +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' + +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()), + } +}) + +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 both playlist track and song for playlist tracks', async () => { + const record = { + id: 'pt-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') + + // Should refresh both the playlist track and the song + expect(getOne).toHaveBeenCalledTimes(2) + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-1', + filter: { playlist_id: 'pl-1' }, + }) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + it('includes playlist_id filter when refreshing playlist tracks', async () => { + const record = { + id: 'pt-5', + mediaFileId: 'sg-10', + playlistId: 'pl-123', + starred: true, + } + const { result } = renderHook(() => + useToggleLove('playlistTrack', record), + ) + await act(async () => { + await result.current[0]() + }) + + // Should unstar using the media file ID + expect(subsonic.unstar).toHaveBeenCalledWith('sg-10') + + // Should refresh playlist track with correct playlist_id filter + expect(getOne).toHaveBeenCalledWith('playlistTrack', { + id: 'pt-5', + filter: { playlist_id: 'pl-123' }, + }) + // Should also refresh the underlying song + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-10' }) + }) + + 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) + expect(getOne).toHaveBeenCalledTimes(1) + expect(getOne).toHaveBeenCalledWith('song', { id: 'sg-1' }) + }) + + 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.jsx new file mode 100644 index 0000000..e447afc --- /dev/null +++ b/ui/src/common/useTraceUpdate.jsx @@ -0,0 +1,18 @@ +import { useRef, useEffect } from 'react' + +export function useTraceUpdate(props) { + const prev = useRef(props) + useEffect(() => { + const changedProps = Object.entries(props).reduce((ps, [k, v]) => { + if (prev.current[k] !== v) { + ps[k] = [prev.current[k], v] + } + 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 new file mode 100644 index 0000000..a53a97d --- /dev/null +++ b/ui/src/config.js @@ -0,0 +1,63 @@ +// These defaults are only used in development mode. When bundled in the app, +// the __APP_CONFIG__ object is dynamically filled by the ServeIndex function, +// in the /server/app/serve_index.go +const defaultConfig = { + version: 'dev', + firstTime: false, + baseURL: '', + variousArtistsId: '63sqASlAfjbGMuLP4JhnZU', // 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, + enableTranscodingConfig: true, + enableDownloads: true, + enableFavourites: true, + losslessFormats: 'FLAC,WAV,ALAC,DSF', + welcomeMessage: '', + gaTrackingId: '', + devActivityPanel: true, + enableStarRating: true, + defaultTheme: 'Dark', + defaultLanguage: '', + defaultUIVolume: 100, + enableUserEditing: true, + enableSharing: true, + shareURL: '', + defaultDownloadableShare: true, + devSidebarPlaylists: true, + lastFMEnabled: true, + listenBrainzEnabled: true, + enableExternalServices: true, + enableCoverAnimation: true, + enableNowPlaying: true, + devShowArtistPage: true, + devUIShowConfig: true, + devNewEventStream: false, + enableReplayGain: true, + defaultDownsamplingFormat: 'opus', + publicBaseUrl: '/share', + separator: '/', + enableInspect: true, +} + +let config + +try { + const appConfig = JSON.parse(window.__APP_CONFIG__) + config = { + ...defaultConfig, + ...appConfig, + } +} catch (e) { + config = defaultConfig +} + +export let shareInfo + +try { + shareInfo = JSON.parse(window.__SHARE_INFO__) +} catch (e) { + shareInfo = null +} + +export default config diff --git a/ui/src/consts.js b/ui/src/consts.js new file mode 100644 index 0000000..e3446c2 --- /dev/null +++ b/ui/src/consts.js @@ -0,0 +1,29 @@ +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 DraggableTypes = { + SONG: 'song', + ALBUM: 'album', + DISC: 'disc', + ARTIST: 'artist', + ALL: [], +} + +DraggableTypes.ALL.push( + DraggableTypes.SONG, + DraggableTypes.ALBUM, + DraggableTypes.DISC, + DraggableTypes.ARTIST, +) + +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() })) diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js new file mode 100644 index 0000000..a8897ae --- /dev/null +++ b/ui/src/dataProvider/httpClient.js @@ -0,0 +1,36 @@ +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 { removeHomeCache } from '../utils/removeHomeCache' + +const customAuthorizationHeader = 'X-ND-Authorization' +const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +const clientUniqueId = uuidv4() + +const httpClient = (url, options = {}) => { + url = baseUrl(url) + if (!options.headers) { + options.headers = new Headers({ Accept: 'application/json' }) + } + options.headers.set(clientUniqueIdHeader, clientUniqueId) + const token = localStorage.getItem('token') + if (token) { + options.headers.set(customAuthorizationHeader, `Bearer ${token}`) + } + return fetchUtils.fetchJson(url, options).then((response) => { + const token = response.headers.get(customAuthorizationHeader) + if (token) { + const decoded = jwtDecode(token) + localStorage.setItem('token', token) + localStorage.setItem('userId', decoded.uid) + // Avoid going to create admin dialog after logout/login without a refresh + config.firstTime = false + removeHomeCache() + } + return response + }) +} + +export default httpClient diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js new file mode 100644 index 0000000..e9ceea7 --- /dev/null +++ b/ui/src/dataProvider/index.js @@ -0,0 +1,6 @@ +import httpClient from './httpClient' +import wrapperDataProvider from './wrapperDataProvider' + +export { httpClient } + +export default wrapperDataProvider diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js new file mode 100644 index 0000000..268d366 --- /dev/null +++ b/ui/src/dataProvider/wrapperDataProvider.js @@ -0,0 +1,225 @@ +import jsonServerProvider from 'ra-data-json-server' +import httpClient from './httpClient' +import { REST_URL } from '../consts' + +const dataProvider = jsonServerProvider(REST_URL, httpClient) + +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 || {} + + let plsId = '0' + plsId = params.filter.playlist_id + if (!isAdmin()) { + params.filter.missing = false + } + 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}`, { + 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 + if (!userData.isAdmin && libraryIds !== undefined) { + await handleUserLibraryAssociation(userId, libraryIds) + } + + return userResponse +} + +const wrapperDataProvider = { + ...dataProvider, + getList: (resource, params) => { + const [r, p] = mapResource(resource, params) + return dataProvider.getList(r, p) + }, + 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 + }, + getMany: (resource, params) => { + const [r, p] = mapResource(resource, params) + return dataProvider.getMany(r, p) + }, + getManyReference: (resource, params) => { + const [r, p] = mapResource(resource, params) + 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) + }, + updateMany: (resource, params) => { + const [r, p] = mapResource(resource, params) + return dataProvider.updateMany(r, p) + }, + create: (resource, params) => { + if (resource === 'user') { + return createUser(params) + } + const [r, p] = mapResource(resource, params) + return dataProvider.create(r, p) + }, + delete: (resource, params) => { + const [r, p] = mapResource(resource, params) + return dataProvider.delete(r, p) + }, + deleteMany: (resource, params) => { + const [r, p] = mapResource(resource, params) + if (r.endsWith('/tracks') || resource === 'missing') { + return callDeleteMany(r, p) + } + return dataProvider.deleteMany(r, p) + }, + addToPlaylist: (playlistId, data) => { + return httpClient(`${REST_URL}/playlist/${playlistId}/tracks`, { + method: 'POST', + body: JSON.stringify(data), + }).then(({ json }) => ({ data: json })) + }, + getPlaylists: (songId) => { + return httpClient(`${REST_URL}/song/${songId}/playlists`).then( + ({ json }) => ({ data: json }), + ) + }, + inspect: (songId) => { + return httpClient(`${REST_URL}/inspect?id=${songId}`).then(({ json }) => ({ + data: json, + })) + }, +} + +export default wrapperDataProvider diff --git a/ui/src/dialogs/AboutDialog.jsx b/ui/src/dialogs/AboutDialog.jsx new file mode 100644 index 0000000..661462b --- /dev/null +++ b/ui/src/dialogs/AboutDialog.jsx @@ -0,0 +1,471 @@ +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 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') + } + } + + return ( + <div className={classes.configContainer}> + <Button + variant="outlined" + startIcon={<FileCopyIcon />} + onClick={handleCopyToml} + className={classes.copyButton} + disabled={!configData} + size="small" + > + {translate('about.config.exportToml')} + </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.jsx new file mode 100644 index 0000000..a751930 --- /dev/null +++ b/ui/src/dialogs/AboutDialog.test.jsx @@ -0,0 +1,57 @@ +import * as React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +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> + </TableRow> + </TableBody> + </Table> +) + +describe('<LinkToVersion />', () => { + afterEach(cleanup) + + it('should not render any link for "dev" version', () => { + const version = 'dev' + render(<Wrapper version={version} />) + expect(screen.queryByRole('link')).toBeNull() + }) + + it('should render link to GH tag page for full releases', () => { + const version = '0.40.0 (300a0292)' + render(<Wrapper version={version} />) + + const link = screen.queryByRole('link') + expect(link.href).toBe( + 'https://github.com/navidrome/navidrome/releases/tag/v0.40.0', + ) + expect(link.textContent).toBe('0.40.0') + + const cell = screen.queryByRole('cell') + expect(cell.textContent).toBe('0.40.0 (300a0292)') + }) + + it('should render link to GH comparison page for snapshot releases', () => { + const version = '0.40.0-SNAPSHOT (300a0292)' + render(<Wrapper version={version} />) + + const link = screen.queryByRole('link') + expect(link.href).toBe( + 'https://github.com/navidrome/navidrome/compare/v0.40.0...300a0292', + ) + expect(link.textContent).toBe('0.40.0-SNAPSHOT') + + const cell = screen.queryByRole('cell') + expect(cell.textContent).toBe('0.40.0-SNAPSHOT (300a0292)') + }) +}) diff --git a/ui/src/dialogs/AddToPlaylistDialog.jsx b/ui/src/dialogs/AddToPlaylistDialog.jsx new file mode 100644 index 0000000..91521d1 --- /dev/null +++ b/ui/src/dialogs/AddToPlaylistDialog.jsx @@ -0,0 +1,195 @@ +import React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + useDataProvider, + useNotify, + useRefresh, + useTranslate, +} from 'react-admin' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + makeStyles, +} from '@material-ui/core' +import { + closeAddToPlaylist, + closeDuplicateSongDialog, + openDuplicateSongWarning, +} from '../actions' +import { SelectPlaylistInput } from './SelectPlaylistInput' +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() + const translate = useTranslate() + const notify = useNotify() + const refresh = useRefresh() + const [value, setValue] = useState({}) + const [check, setCheck] = useState(false) + const dataProvider = useDataProvider() + const createAndAddToPlaylist = (playlistObject) => { + dataProvider + .create('playlist', { + data: { name: playlistObject.name }, + }) + .then((res) => { + addToPlaylist(res.data.id) + }) + .catch((error) => notify(`Error: ${error.message}`, 'warning')) + } + + const addToPlaylist = (playlistId, distinctIds) => { + const trackIds = Array.isArray(distinctIds) ? distinctIds : selectedIds + if (trackIds.length) { + dataProvider + .create('playlistTrack', { + data: { ids: trackIds }, + filter: { playlist_id: playlistId }, + }) + .then(() => { + const len = trackIds.length + notify('message.songsAddedToPlaylist', { + messageArgs: { smart_count: len }, + }) + onSuccess && onSuccess(value, len) + refresh() + }) + .catch(() => { + notify('ra.page.error', { type: 'warning' }) + }) + } else { + notify('message.songsAddedToPlaylist', { + messageArgs: { smart_count: 0 }, + }) + } + } + + const checkDuplicateSong = (playlistObject) => { + httpClient(`${REST_URL}/playlist/${playlistObject.id}/tracks`) + .then((res) => { + const tracks = res.json + if (tracks) { + const dupSng = tracks.filter((song) => + selectedIds.some((id) => id === song.mediaFileId), + ) + + if (dupSng.length) { + const dupIds = dupSng.map((song) => song.mediaFileId) + dispatch(openDuplicateSongWarning(dupIds)) + } + } + setCheck(true) + }) + .catch(() => { + notify('ra.page.error', 'warning') + }) + } + + const handleSubmit = (e) => { + value.forEach((playlistObject) => { + if (playlistObject.id) { + addToPlaylist(playlistObject.id, playlistObject.distinctIds) + } else { + createAndAddToPlaylist(playlistObject) + } + }) + setCheck(false) + setValue({}) + dispatch(closeAddToPlaylist()) + e.stopPropagation() + } + + const handleClickClose = (e) => { + setCheck(false) + setValue({}) + dispatch(closeAddToPlaylist()) + e.stopPropagation() + } + + const handleChange = (pls) => { + if (!value.length || pls.length > value.length) { + let newlyAdded = pls.slice(-1).pop() + if (newlyAdded.id) { + setCheck(false) + checkDuplicateSong(newlyAdded) + } else setCheck(true) + } else if (pls.length === 0) setCheck(false) + setValue(pls) + } + + const handleDuplicateClose = () => { + dispatch(closeDuplicateSongDialog()) + } + const handleDuplicateSubmit = () => { + dispatch(closeDuplicateSongDialog()) + } + const handleSkip = () => { + const distinctSongs = selectedIds.filter( + (id) => duplicateIds.indexOf(id) < 0, + ) + value.slice(-1).pop().distinctIds = distinctSongs + dispatch(closeDuplicateSongDialog()) + } + + return ( + <> + <Dialog + open={open} + onClose={handleClickClose} + 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}> + <SelectPlaylistInput onChange={handleChange} /> + </DialogContent> + <DialogActions> + <Button onClick={handleClickClose} color="primary"> + {translate('ra.action.cancel')} + </Button> + <Button + onClick={handleSubmit} + color="primary" + disabled={!check} + data-testid="playlist-add" + > + {translate('ra.action.add')} + </Button> + </DialogActions> + </Dialog> + <DuplicateSongDialog + open={duplicateSong} + handleClickClose={handleDuplicateClose} + handleSubmit={handleDuplicateSubmit} + handleSkip={handleSkip} + /> + </> + ) +} diff --git a/ui/src/dialogs/AddToPlaylistDialog.test.jsx b/ui/src/dialogs/AddToPlaylistDialog.test.jsx new file mode 100644 index 0000000..60d3cca --- /dev/null +++ b/ui/src/dialogs/AddToPlaylistDialog.test.jsx @@ -0,0 +1,196 @@ +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 { AddToPlaylistDialog } from './AddToPlaylistDialog' +import { describe, beforeAll, afterEach, it, expect, vi } from 'vitest' + +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 selectedIds = ['song-1', 'song-2'] + +const createTestUtils = (mockDataProvider) => + render( + <DataProviderContext.Provider value={mockDataProvider}> + <TestContext + initialState={{ + addToPlaylistDialog: { + open: true, + duplicateSong: false, + selectedIds: selectedIds, + }, + 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, + }, + }, + }, + }, + }, + }, + }} + > + <AddToPlaylistDialog /> + </TestContext> + </DataProviderContext.Provider>, + ) + +vi.mock('../dataProvider', () => ({ + ...vi.importActual('../dataProvider'), + httpClient: vi.fn(), +})) + +describe('AddToPlaylistDialog', () => { + beforeAll(() => localStorage.setItem('userId', 'admin')) + afterEach(cleanup) + + it('adds distinct songs to already existing playlists', async () => { + const dataProvider = await import('../dataProvider') + vi.spyOn(dataProvider, 'httpClient').mockResolvedValue({ data: mockData }) + + const mockDataProvider = { + getList: vi + .fn() + .mockResolvedValue({ data: mockData, total: mockData.length }), + getOne: vi.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), + create: vi.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) + + await waitFor(() => { + expect(screen.getByTestId('playlist-add')).not.toBeDisabled() + }) + fireEvent.click(screen.getByTestId('playlist-add')) + await waitFor(() => { + expect(mockDataProvider.create).toHaveBeenNthCalledWith( + 1, + 'playlistTrack', + { + data: { ids: selectedIds }, + filter: { playlist_id: 'sample-id1' }, + }, + ) + }) + await waitFor(() => { + expect(mockDataProvider.create).toHaveBeenNthCalledWith( + 2, + 'playlistTrack', + { + data: { ids: selectedIds }, + filter: { playlist_id: 'sample-id2' }, + }, + ) + }) + }) + + it('adds distinct songs to a new playlist', async () => { + const mockDataProvider = { + getList: vi + .fn() + .mockResolvedValue({ data: mockData, total: mockData.length }), + getOne: vi.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), + create: vi.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: 'Enter' }) + + await waitFor(() => { + expect(screen.getByTestId('playlist-add')).not.toBeDisabled() + }) + fireEvent.click(screen.getByTestId('playlist-add')) + await waitFor(() => { + expect(mockDataProvider.create).toHaveBeenNthCalledWith(1, 'playlist', { + data: { name: 'sample' }, + }) + }) + expect(mockDataProvider.create).toHaveBeenNthCalledWith( + 2, + 'playlistTrack', + { + data: { ids: selectedIds }, + filter: { playlist_id: 'created-id1' }, + }, + ) + }) + + it('adds distinct songs to multiple new playlists', async () => { + const mockDataProvider = { + getList: vi + .fn() + .mockResolvedValue({ data: mockData, total: mockData.length }), + getOne: vi.fn().mockResolvedValue({ data: { id: 'song-3' }, total: 1 }), + create: vi.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: '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() + }) + fireEvent.click(screen.getByTestId('playlist-add')) + await waitFor(() => { + expect(mockDataProvider.create).toHaveBeenCalledTimes(4) + }) + }) +}) diff --git a/ui/src/dialogs/DialogContent.jsx b/ui/src/dialogs/DialogContent.jsx new file mode 100644 index 0000000..cc65061 --- /dev/null +++ b/ui/src/dialogs/DialogContent.jsx @@ -0,0 +1,8 @@ +import { withStyles } from '@material-ui/core/styles' +import MuiDialogContent from '@material-ui/core/DialogContent' + +export const DialogContent = withStyles((theme) => ({ + root: { + padding: theme.spacing(2), + }, +}))(MuiDialogContent) diff --git a/ui/src/dialogs/DialogTitle.jsx b/ui/src/dialogs/DialogTitle.jsx new file mode 100644 index 0000000..959031e --- /dev/null +++ b/ui/src/dialogs/DialogTitle.jsx @@ -0,0 +1,35 @@ +import { withStyles } from '@material-ui/core/styles' +import MuiDialogTitle from '@material-ui/core/DialogTitle' +import Typography from '@material-ui/core/Typography' +import IconButton from '@material-ui/core/IconButton' +import CloseIcon from '@material-ui/icons/Close' +import React from 'react' + +const styles = (theme) => ({ + root: { + margin: 0, + padding: theme.spacing(2), + }, + closeButton: { + position: 'absolute', + right: theme.spacing(1), + top: theme.spacing(1), + color: theme.palette.grey[500], + }, +}) + +export const DialogTitle = withStyles(styles)((props) => { + const { children, classes, onClose, ...other } = props + return ( + <MuiDialogTitle disableTypography className={classes.root} {...other}> + <Typography variant="h5">{children}</Typography> + <IconButton + aria-label="close" + className={classes.closeButton} + onClick={onClose} + > + <CloseIcon /> + </IconButton> + </MuiDialogTitle> + ) +}) diff --git a/ui/src/dialogs/Dialogs.jsx b/ui/src/dialogs/Dialogs.jsx new file mode 100644 index 0000000..f37c558 --- /dev/null +++ b/ui/src/dialogs/Dialogs.jsx @@ -0,0 +1,15 @@ +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.jsx new file mode 100644 index 0000000..2104cbc --- /dev/null +++ b/ui/src/dialogs/DownloadMenuDialog.jsx @@ -0,0 +1,81 @@ +import { SimpleForm, useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@material-ui/core' +import subsonic from '../subsonic' +import { closeDownloadMenu } from '../actions' +import { formatBytes } from '../utils' +import { useTranscodingOptions } from './useTranscodingOptions' + +const DownloadMenuDialog = () => { + const { open, record, recordType } = useSelector( + (state) => state.downloadMenuDialog, + ) + const dispatch = useDispatch() + const translate = useTranslate() + + const { TranscodingOptionsInput, format, maxBitRate, originalFormat } = + useTranscodingOptions() + + const handleClose = (e) => { + dispatch(closeDownloadMenu()) + e.stopPropagation() + } + + const handleDownload = (e) => { + if (record) { + const id = record.mediaFileId || record.id + if (originalFormat) { + subsonic.download(id, 'raw') + } else { + subsonic.download(id, format, maxBitRate?.toString()) + } + dispatch(closeDownloadMenu()) + } + e.stopPropagation() + } + + return ( + <Dialog + open={open} + onClose={handleClose} + aria-labelledby="download-dialog" + fullWidth={true} + maxWidth={'sm'} + > + <DialogTitle id="download-dialog"> + {recordType && + translate('message.downloadDialogTitle', { + resource: translate(`resources.${recordType}.name`, { + smart_count: 1, + }).toLocaleLowerCase(), + name: record?.name || record?.title, + size: formatBytes(record?.size), + })} + </DialogTitle> + <DialogContent> + <SimpleForm toolbar={null} variant={'outlined'}> + <TranscodingOptionsInput + fullWidth + label={translate('message.downloadOriginalFormat')} + /> + </SimpleForm> + </DialogContent> + <DialogActions> + <Button onClick={handleClose} color="secondary"> + {translate('ra.action.close')} + </Button> + <Button onClick={handleDownload} color="primary"> + {translate('ra.action.download')} + </Button> + </DialogActions> + </Dialog> + ) +} + +export default DownloadMenuDialog diff --git a/ui/src/dialogs/DuplicateSongDialog.jsx b/ui/src/dialogs/DuplicateSongDialog.jsx new file mode 100644 index 0000000..2648a23 --- /dev/null +++ b/ui/src/dialogs/DuplicateSongDialog.jsx @@ -0,0 +1,47 @@ +import React from 'react' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@material-ui/core' + +import { useTranslate } from 'react-admin' + +const DuplicateSongDialog = ({ + open, + handleClickClose, + handleSubmit, + handleSkip, +}) => { + const translate = useTranslate() + + return ( + <Dialog + open={open} + onClose={handleClickClose} + aria-labelledby="form-dialog-duplicate-song" + > + <DialogTitle id="form-dialog-duplicate-song"> + {translate('resources.playlist.message.duplicate_song')} + </DialogTitle> + <DialogContent> + {translate('resources.playlist.message.song_exist')} + </DialogContent> + <DialogActions> + <Button onClick={handleClickClose} color="primary"> + {translate('ra.action.cancel')} + </Button> + <Button onClick={handleSkip} color="primary"> + {translate('ra.action.skip')} + </Button> + <Button onClick={handleSubmit} color="primary"> + {translate('ra.action.add')} + </Button> + </DialogActions> + </Dialog> + ) +} + +export default DuplicateSongDialog diff --git a/ui/src/dialogs/ExpandInfoDialog.jsx b/ui/src/dialogs/ExpandInfoDialog.jsx new file mode 100644 index 0000000..be84546 --- /dev/null +++ b/ui/src/dialogs/ExpandInfoDialog.jsx @@ -0,0 +1,56 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useDispatch, useSelector } from 'react-redux' +import { RecordContextProvider, useTranslate } from 'react-admin' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@material-ui/core' +import { closeExtendedInfoDialog } from '../actions' + +const ExpandInfoDialog = ({ title, content }) => { + const { open, record } = useSelector((state) => state.expandInfoDialog) + const dispatch = useDispatch() + const translate = useTranslate() + + const handleClose = (e) => { + dispatch(closeExtendedInfoDialog()) + e.stopPropagation() + } + + return ( + <Dialog + open={open} + onClose={handleClose} + aria-labelledby="info-dialog-album" + fullWidth={true} + maxWidth={'md'} + > + <DialogTitle id="info-dialog-album"> + {translate(title || 'resources.song.actions.info')} + </DialogTitle> + <DialogContent> + {record && ( + <RecordContextProvider value={record}> + {content} + </RecordContextProvider> + )} + </DialogContent> + <DialogActions> + <Button onClick={handleClose} color="primary"> + {translate('ra.action.close')} + </Button> + </DialogActions> + </Dialog> + ) +} + +ExpandInfoDialog.propTypes = { + title: PropTypes.string, + content: PropTypes.object.isRequired, +} + +export default ExpandInfoDialog diff --git a/ui/src/dialogs/HelpDialog.jsx b/ui/src/dialogs/HelpDialog.jsx new file mode 100644 index 0000000..1aa9db6 --- /dev/null +++ b/ui/src/dialogs/HelpDialog.jsx @@ -0,0 +1,79 @@ +import React, { useCallback, useState } from 'react' +import ReactDOM from 'react-dom' +import { Chip, Dialog } from '@material-ui/core' +import { getApplicationKeyMap, GlobalHotKeys } from 'react-hotkeys' +import TableContainer from '@material-ui/core/TableContainer' +import Paper from '@material-ui/core/Paper' +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 { useTranslate } from 'react-admin' +import { humanize } from 'inflection' +import { keyMap } from '../hotkeys' +import { DialogTitle } from './DialogTitle' +import { DialogContent } from './DialogContent' + +const HelpTable = (props) => { + const keyMap = getApplicationKeyMap() + const translate = useTranslate() + return ReactDOM.createPortal( + <Dialog {...props}> + <DialogTitle onClose={props.onClose}> + {translate('help.title')} + </DialogTitle> + <DialogContent dividers> + <TableContainer component={Paper}> + <Table size="small"> + <TableBody> + {Object.keys(keyMap).map((key) => { + const { sequences, name } = keyMap[key] + const description = translate(`help.hotkeys.${name}`, { + _: humanize(name), + }) + return ( + <TableRow key={key}> + <TableCell align="right" component="th" scope="row"> + {description} + </TableCell> + <TableCell align="left"> + {sequences.map(({ sequence }) => ( + <Chip + label={<kbd>{sequence}</kbd>} + size="small" + variant={'outlined'} + key={sequence} + /> + ))} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </TableContainer> + </DialogContent> + </Dialog>, + document.body, + ) +} + +export const HelpDialog = (props) => { + const [open, setOpen] = useState(false) + + const handleClickClose = (e) => { + setOpen(false) + e.stopPropagation() + } + + const handlers = { + SHOW_HELP: useCallback(() => setOpen(true), [setOpen]), + } + + return ( + <> + <GlobalHotKeys keyMap={keyMap} handlers={handlers} allowChanges /> + <HelpTable open={open} onClose={handleClickClose} /> + </> + ) +} diff --git a/ui/src/dialogs/ListenBrainzTokenDialog.jsx b/ui/src/dialogs/ListenBrainzTokenDialog.jsx new file mode 100644 index 0000000..8966675 --- /dev/null +++ b/ui/src/dialogs/ListenBrainzTokenDialog.jsx @@ -0,0 +1,137 @@ +import React, { createRef, useCallback, useState } from 'react' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + LinearProgress, + Link, + TextField, +} from '@material-ui/core' +import { useNotify, useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { closeListenBrainzTokenDialog } from '../actions' +import { httpClient } from '../dataProvider' + +export const ListenBrainzTokenDialog = ({ setLinked }) => { + const dispatch = useDispatch() + const notify = useNotify() + const translate = useTranslate() + const { open } = useSelector((state) => state.listenBrainzTokenDialog) + const [token, setToken] = useState('') + const [checking, setChecking] = useState(false) + const inputRef = createRef() + + const handleChange = (event) => { + setToken(event.target.value) + } + + const handleLinkClick = (event) => { + inputRef.current.focus() + } + + const handleSave = useCallback( + (event) => { + setChecking(true) + httpClient('/api/listenbrainz/link', { + method: 'PUT', + body: JSON.stringify({ token: token }), + }) + .then((response) => { + notify('message.listenBrainzLinkSuccess', 'success', { + user: response.json.user, + }) + setLinked(true) + setToken('') + }) + .catch((error) => { + notify('message.listenBrainzLinkFailure', 'warning', { + error: error.body?.error || error.message, + }) + setLinked(false) + }) + .finally(() => { + setChecking(false) + dispatch(closeListenBrainzTokenDialog()) + event.stopPropagation() + }) + }, + [dispatch, notify, setLinked, token], + ) + + const handleClickClose = (event) => { + if (!checking) { + dispatch(closeListenBrainzTokenDialog()) + event.stopPropagation() + } + } + + const handleKeyPress = useCallback( + (event) => { + if (event.key === 'Enter' && token !== '') { + handleSave(event) + } + }, + [token, handleSave], + ) + + return ( + <> + <Dialog + open={open} + onClose={handleClickClose} + aria-labelledby="form-dialog-listenbrainz-token" + fullWidth={true} + maxWidth="md" + > + <DialogTitle id="form-dialog-listenbrainz-token"> + ListenBrainz + </DialogTitle> + <DialogContent> + <DialogContentText> + {translate('resources.user.message.listenBrainzToken')}{' '} + <Link + href="https://listenbrainz.org/profile/" + onClick={handleLinkClick} + target="_blank" + > + {translate('resources.user.message.clickHereForToken')} + </Link> + </DialogContentText> + <TextField + value={token} + onKeyPress={handleKeyPress} + onChange={handleChange} + disabled={checking} + required + autoFocus + fullWidth={true} + variant={'outlined'} + label={translate('resources.user.fields.token')} + inputRef={inputRef} + /> + {checking && <LinearProgress />} + </DialogContent> + <DialogActions> + <Button + onClick={handleClickClose} + disabled={checking} + color="primary" + > + {translate('ra.action.cancel')} + </Button> + <Button + onClick={handleSave} + disabled={checking || token === ''} + color="primary" + data-testid="listenbrainz-token-save" + > + {translate('ra.action.save')} + </Button> + </DialogActions> + </Dialog> + </> + ) +} diff --git a/ui/src/dialogs/SaveQueueDialog.jsx b/ui/src/dialogs/SaveQueueDialog.jsx new file mode 100644 index 0000000..f916a07 --- /dev/null +++ b/ui/src/dialogs/SaveQueueDialog.jsx @@ -0,0 +1,120 @@ +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 new file mode 100644 index 0000000..c3a9138 --- /dev/null +++ b/ui/src/dialogs/SaveQueueDialog.test.jsx @@ -0,0 +1,91 @@ +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.jsx b/ui/src/dialogs/SelectPlaylistInput.jsx new file mode 100644 index 0000000..d401dd8 --- /dev/null +++ b/ui/src/dialogs/SelectPlaylistInput.jsx @@ -0,0 +1,401 @@ +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 { isWritable } 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) => isWritable(option.ownerId)) + + // 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.jsx b/ui/src/dialogs/SelectPlaylistInput.test.jsx new file mode 100644 index 0000000..4ffcdf0 --- /dev/null +++ b/ui/src/dialogs/SelectPlaylistInput.test.jsx @@ -0,0 +1,489 @@ +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 +] + +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' }, +} + +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() + }) + + 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.jsx new file mode 100644 index 0000000..21856b9 --- /dev/null +++ b/ui/src/dialogs/ShareDialog.jsx @@ -0,0 +1,146 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, +} from '@material-ui/core' +import { + SimpleForm, + TextInput, + BooleanInput, + useCreate, + useNotify, + useTranslate, +} from 'react-admin' +import { useEffect, useState } from 'react' +import { sharePlayerUrl } from '../utils' +import { useTranscodingOptions } from './useTranscodingOptions' +import { useDispatch, useSelector } from 'react-redux' +import { closeShareMenu } from '../actions' +import config from '../config' + +export const ShareDialog = () => { + const { + open, + ids, + resource, + name, + label = 'message.shareDialogTitle', + } = useSelector((state) => state.shareDialog) + const dispatch = useDispatch() + const notify = useNotify() + const translate = useTranslate() + const [description, setDescription] = useState('') + const [downloadable, setDownloadable] = useState( + config.defaultDownloadableShare && config.enableDownloads, + ) + useEffect(() => { + setDescription('') + }, [ids]) + const { TranscodingOptionsInput, format, maxBitRate, originalFormat } = + useTranscodingOptions() + const [createShare] = useCreate( + 'share', + { + resourceType: resource, + resourceIds: ids?.join(','), + description, + downloadable, + ...(!originalFormat && { format }), + ...(!originalFormat && { maxBitRate }), + }, + { + onSuccess: (res) => { + const url = sharePlayerUrl(res?.data?.id) + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard + .writeText(url) + .then(() => { + notify('message.shareSuccess', 'info', { url }, false, 0) + }) + .catch((err) => { + notify( + translate('message.shareFailure', { url }) + ': ' + err.message, + { + type: 'warning', + multiLine: true, + duration: 0, + }, + ) + }) + } else prompt(translate('message.shareCopyToClipboard'), url) + }, + onFailure: (error) => + notify(translate('ra.page.error') + ': ' + error.message, { + type: 'warning', + }), + }, + ) + + const handleShare = (e) => { + createShare() + dispatch(closeShareMenu()) + e.stopPropagation() + } + + const handleClose = (e) => { + dispatch(closeShareMenu()) + e.stopPropagation() + } + + return ( + <Dialog + open={open} + onClose={handleClose} + aria-labelledby="share-dialog" + fullWidth={true} + maxWidth={'sm'} + > + <DialogTitle id="share-dialog"> + {resource && + translate(label, { + resource: translate(`resources.${resource}.name`, { + smart_count: ids?.length, + }).toLocaleLowerCase(), + name, + smart_count: ids?.length, + })} + </DialogTitle> + <DialogContent> + <SimpleForm toolbar={null} variant={'outlined'}> + <TextInput + resource={'share'} + source={'description'} + fullWidth + onChange={(event) => { + setDescription(event.target.value) + }} + /> + {config.enableDownloads && ( + <BooleanInput + resource={'share'} + source={'downloadable'} + defaultValue={downloadable} + onChange={(value) => { + setDownloadable(value) + }} + /> + )} + <TranscodingOptionsInput + fullWidth + label={translate('message.shareOriginalFormat')} + /> + </SimpleForm> + </DialogContent> + <DialogActions> + <Button onClick={handleClose} color="primary"> + {translate('ra.action.close')} + </Button> + <Button onClick={handleShare} color="primary"> + {translate('ra.action.share')} + </Button> + </DialogActions> + </Dialog> + ) +} diff --git a/ui/src/dialogs/aboutUtils.js b/ui/src/dialogs/aboutUtils.js new file mode 100644 index 0000000..7c92692 --- /dev/null +++ b/ui/src/dialogs/aboutUtils.js @@ -0,0 +1,278 @@ +/** + * 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 new file mode 100644 index 0000000..4632c5a --- /dev/null +++ b/ui/src/dialogs/aboutUtils.test.js @@ -0,0 +1,737 @@ +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 new file mode 100644 index 0000000..86586ae --- /dev/null +++ b/ui/src/dialogs/index.js @@ -0,0 +1,5 @@ +export * from './AboutDialog' +export * from './SelectPlaylistInput' +export * from './ListenBrainzTokenDialog' +export * from './SaveQueueDialog' +export * from './Dialogs' diff --git a/ui/src/dialogs/useDialog.jsx b/ui/src/dialogs/useDialog.jsx new file mode 100644 index 0000000..9ec5490 --- /dev/null +++ b/ui/src/dialogs/useDialog.jsx @@ -0,0 +1,30 @@ +import { useCallback, useMemo, useState } from 'react' + +// Idea from https://blog.bitsrc.io/new-react-design-pattern-return-component-from-hooks-79215c3eac00 +export const useDialog = () => { + const [anchorEl, setAnchorEl] = useState(null) + + const openDialog = useCallback((event) => { + event?.stopPropagation() + setAnchorEl(event.currentTarget) + }, []) + + const closeDialog = useCallback((event) => { + event?.stopPropagation() + setAnchorEl(null) + }, []) + + const props = useMemo(() => { + return { + anchorEl, + open: Boolean(anchorEl), + onClose: closeDialog, + } + }, [anchorEl, closeDialog]) + + return { + openDialog, + closeDialog, + props, + } +} diff --git a/ui/src/dialogs/useTranscodingOptions.jsx b/ui/src/dialogs/useTranscodingOptions.jsx new file mode 100644 index 0000000..c7d44a2 --- /dev/null +++ b/ui/src/dialogs/useTranscodingOptions.jsx @@ -0,0 +1,104 @@ +import React, { useCallback, useMemo, useState } from 'react' +import config from '../config' +import { BITRATE_CHOICES, DEFAULT_SHARE_BITRATE } from '../consts' +import { + BooleanInput, + SelectInput, + useGetList, + useTranslate, +} from 'react-admin' + +export const useTranscodingOptions = () => { + const translate = useTranslate() + const [format, setFormat] = useState(config.defaultDownsamplingFormat) + const [maxBitRate, setMaxBitRate] = useState(DEFAULT_SHARE_BITRATE) + const [originalFormat, setUseOriginalFormat] = useState(true) + + const { data: formats, loading: loadingFormats } = useGetList( + 'transcoding', + { + page: 1, + perPage: 1000, + }, + { field: 'name', order: 'ASC' }, + ) + + const formatOptions = useMemo( + () => + loadingFormats + ? [] + : Object.values(formats).map((f) => { + return { id: f.targetFormat, name: f.name } + }), + [formats, loadingFormats], + ) + + const handleOriginal = useCallback( + (original) => { + setUseOriginalFormat(original) + if (original) { + setFormat(config.defaultDownsamplingFormat) + setMaxBitRate(DEFAULT_SHARE_BITRATE) + } + }, + [setUseOriginalFormat, setFormat, setMaxBitRate], + ) + + const TranscodingOptionsInput = useMemo(() => { + const Component = ({ label, basePath, ...props }) => { + return ( + <> + <BooleanInput + {...props} + source="original" + defaultValue={originalFormat} + label={label} + fullWidth + onChange={handleOriginal} + /> + {!originalFormat && ( + <> + <SelectInput + {...props} + source="format" + defaultValue={format} + label={translate('resources.player.fields.transcodingId')} + choices={formatOptions} + onChange={(event) => { + setFormat(event.target.value) + }} + /> + <SelectInput + {...props} + source="maxBitRate" + label={translate('resources.player.fields.maxBitRate')} + defaultValue={maxBitRate} + choices={BITRATE_CHOICES} + onChange={(event) => { + setMaxBitRate(event.target.value) + }} + /> + </> + )} + </> + ) + } + + Component.displayName = 'TranscodingOptionsInput' + return Component + }, [ + handleOriginal, + formatOptions, + format, + maxBitRate, + originalFormat, + translate, + ]) + + return { + TranscodingOptionsInput, + format, + maxBitRate, + originalFormat, + } +} diff --git a/ui/src/eventStream.js b/ui/src/eventStream.js new file mode 100644 index 0000000..c91dae8 --- /dev/null +++ b/ui/src/eventStream.js @@ -0,0 +1,114 @@ +import { baseUrl } from './utils' +import throttle from 'lodash.throttle' +import { processEvent, serverDown, streamReconnected } from './actions' +import { REST_URL } from './consts' +import config from './config' + +const newEventStream = async () => { + let url = baseUrl(`${REST_URL}/events`) + if (localStorage.getItem('token')) { + url = url + `?jwt=${localStorage.getItem('token')}` + } + 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') { + dispatchFn(processEvent(event.type, data)) + } +} + +const throttledEventHandler = (dispatchFn) => + throttle(eventHandler(dispatchFn), 100, { trailing: true }) + +const startEventStreamLegacy = async (dispatchFn) => { + return newEventStream() + .then((newStream) => { + newStream.addEventListener('serverStart', eventHandler(dispatchFn)) + newStream.addEventListener( + 'scanStatus', + 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 new file mode 100644 index 0000000..27f53c8 --- /dev/null +++ b/ui/src/eventStream.test.js @@ -0,0 +1,51 @@ +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/hotkeys.js b/ui/src/hotkeys.js new file mode 100644 index 0000000..dc7bfb6 --- /dev/null +++ b/ui/src/hotkeys.js @@ -0,0 +1,16 @@ +import config from './config' +const keyMap = { + SHOW_HELP: { name: 'show_help', sequence: 'shift+?', group: 'Global' }, + TOGGLE_MENU: { name: 'toggle_menu', sequence: 'm', group: 'Global' }, + TOGGLE_PLAY: { name: 'toggle_play', sequence: 'space', group: 'Player' }, + PREV_SONG: { name: 'prev_song', sequence: 'left', group: 'Player' }, + NEXT_SONG: { name: 'next_song', sequence: 'right', group: 'Player' }, + CURRENT_SONG: { name: 'current_song', sequence: 'shift+c', group: 'Player' }, + VOL_UP: { name: 'vol_up', sequence: '=', group: 'Player' }, + VOL_DOWN: { name: 'vol_down', sequence: '-', group: 'Player' }, + ...(config.enableFavourites && { + TOGGLE_LOVE: { name: 'toggle_love', sequence: 'l', group: 'Player' }, + }), +} + +export { keyMap } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json new file mode 100644 index 0000000..9ef65d6 --- /dev/null +++ b/ui/src/i18n/en.json @@ -0,0 +1,636 @@ +{ + "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", + "libraryName": "Library", + "genre": "Genre", + "compilation": "Compilation", + "year": "Year", + "size": "File size", + "updatedAt": "Updated at", + "bitRate": "Bit rate", + "bitDepth": "Bit depth", + "sampleRate": "Sample rate", + "channels": "Channels", + "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" + } + }, + "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" + }, + "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", + "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" + }, + "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" + }, + "notifications": { + "created": "User created", + "updated": "User updated", + "deleted": "User deleted" + }, + "validation": { + "librariesRequired": "At least one library must be selected for non-admin users" + }, + "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", + "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", + "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" + }, + "sections": { + "basic": "Basic Information", + "statistics": "Statistics" + }, + "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" + } + } + }, + "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" + } + }, + "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": { + "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", + "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", + "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", + "version": "Version", + "theme": "Theme", + "personal": { + "name": "Personal", + "options": { + "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" + } + } + }, + "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" + } + }, + "about": { + "links": { + "homepage": "Home page", + "source": "Source code", + "featureRequests": "Feature requests", + "lastInsightsCollection": "Last insights collection", + "insights": { + "disabled": "Disabled", + "waiting": "Waiting" + } + }, + "tabs": { + "about": "About", + "config": "Configuration" + }, + "config": { + "configName": "Config Name", + "environmentVariable": "Environment Variable", + "currentValue": "Current Value", + "configurationFile": "Configuration File", + "exportToml": "Export 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" + } + }, + "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/index.js b/ui/src/i18n/index.js new file mode 100644 index 0000000..984757c --- /dev/null +++ b/ui/src/i18n/index.js @@ -0,0 +1,5 @@ +import i18nProvider from './provider' +import { retrieveTranslation } from './provider' +import useGetLanguageChoices from './useGetLanguageChoices' + +export { i18nProvider, retrieveTranslation, useGetLanguageChoices } diff --git a/ui/src/i18n/provider.js b/ui/src/i18n/provider.js new file mode 100644 index 0000000..f17a5b4 --- /dev/null +++ b/ui/src/i18n/provider.js @@ -0,0 +1,66 @@ +import polyglotI18nProvider from 'ra-i18n-polyglot' +import deepmerge from 'deepmerge' +import dataProvider from '../dataProvider' +import en from './en.json' +import { i18nProvider } from './index' + +// Only returns current selected locale if its translations are found in localStorage +const defaultLocale = function () { + const locale = localStorage.getItem('locale') + const current = JSON.parse(localStorage.getItem('translation')) + if (current && current.id === locale) { + // Asynchronously reload the translation from the server + retrieveTranslation(locale).then(() => { + i18nProvider.changeLocale(locale) + }) + return locale + } + return 'en' +} + +export function retrieveTranslation(locale) { + return dataProvider.getOne('translation', { id: locale }).then((res) => { + localStorage.setItem('translation', JSON.stringify(res.data)) + return prepareLanguage(JSON.parse(res.data.data)) + }) +} + +const removeEmpty = (obj) => { + for (let k in obj) { + if ( + Object.prototype.hasOwnProperty.call(obj, k) && + typeof obj[k] === 'object' + ) { + removeEmpty(obj[k]) + } else { + if (!obj[k]) { + delete obj[k] + } + } + } +} + +const prepareLanguage = (lang) => { + removeEmpty(lang) + // Make "albumSong" and "playlistTrack" resource use the same translations as "song" + lang.resources.albumSong = lang.resources.song + lang.resources.playlistTrack = lang.resources.song + // ra.boolean.null should always be empty + lang.ra.boolean.null = '' + // Fallback to english translations + return deepmerge(en, lang) +} + +export default polyglotI18nProvider((locale) => { + // English is bundled + if (locale === 'en') { + return prepareLanguage(en) + } + // If the requested locale is in already loaded, return it + const current = JSON.parse(localStorage.getItem('translation')) + if (current && current.id === locale) { + return prepareLanguage(JSON.parse(current.data)) + } + // If not, get it from the server, and store it in localStorage + return retrieveTranslation(locale) +}, defaultLocale()) diff --git a/ui/src/i18n/useGetLanguageChoices.jsx b/ui/src/i18n/useGetLanguageChoices.jsx new file mode 100644 index 0000000..0c70869 --- /dev/null +++ b/ui/src/i18n/useGetLanguageChoices.jsx @@ -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/icons/MusicBrainz.jsx b/ui/src/icons/MusicBrainz.jsx new file mode 100644 index 0000000..381d891 --- /dev/null +++ b/ui/src/icons/MusicBrainz.jsx @@ -0,0 +1,20 @@ +import React from 'react' +import SvgIcon from '@material-ui/core/SvgIcon' + +const MusicBrainz = (props) => { + return ( + <SvgIcon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" {...props}> + <defs> + <linearGradient id="mb-icon-gradient" x2="1"> + <stop stopColor="var(--mb-icon-color-one, #FFF)" offset="0%" /> + <stop stopColor="var(--mb-icon-color-one, #FFF)" offset="50%" /> + <stop stopColor="var(--mb-icon-color-two, #FFF)" offset="50%" /> + <stop stopColor="var(--mb-icon-color-two, #FFF)" offset="100%" /> + </linearGradient> + </defs> + <path d="M11.582 0L1.418 5.832v12.336L11.582 24V10.01L7.1 12.668v3.664c.01.111.01.225 0 .336-.103.435-.54.804-1 1.111-.802.537-1.752.509-2.166-.111-.413-.62-.141-1.631.666-2.168.384-.28.863-.399 1.334-.332V6.619c0-.154.134-.252.226-.308L11.582 3zm.836 0v6.162c.574.03 1.14.16 1.668.387a2.225 2.225 0 0 0 1.656-.717 1.02 1.02 0 1 1 1.832-.803l.004.006a1.022 1.022 0 0 1-1.295 1.197c-.34.403-.792.698-1.297.85.34.263.641.576.891.928a1.04 1.04 0 0 1 .777.125c.768.486.568 1.657-.318 1.857-.886.2-1.574-.77-1.09-1.539.02-.03.042-.06.065-.09a3.598 3.598 0 0 0-1.436-1.166 4.142 4.142 0 0 0-1.457-.369v4.01c.855.06 1.256.493 1.555.834.227.256.356.39.578.402.323.018.568.008.806 0a5.44 5.44 0 0 1 .895.022c.94-.017 1.272-.226 1.605-.446a2.533 2.533 0 0 1 1.131-.463 1.027 1.027 0 0 1 .12-.263 1.04 1.04 0 0 1 .105-.137c.023-.025.047-.044.07-.066a4.775 4.775 0 0 1 0-2.405l-.012-.01a1.02 1.02 0 1 1 .692.272h-.057a4.288 4.288 0 0 0 0 1.877h.063a1.02 1.02 0 1 1-.545 1.883l-.047-.033a1 1 0 0 1-.352-.442 1.885 1.885 0 0 0-.814.354 3.03 3.03 0 0 1-.703.365c.757.555 1.772 1.6 2.199 2.299a1.03 1.03 0 0 1 .256-.033 1.02 1.02 0 1 1-.545 1.88l-.047-.03a1.017 1.017 0 0 1-.27-1.376.72.72 0 0 1 .051-.072c-.445-.775-2.026-2.28-2.46-2.387a4.037 4.037 0 0 0-1.31-.117c-.24.008-.513.018-.866 0-.515-.027-.783-.333-1.043-.629-.26-.296-.51-.56-1.055-.611V18.5a1.877 1.877 0 0 0 .426-.135.333.333 0 0 1 .058-.027c.56-.267 1.421-.91 2.096-2.447a1.02 1.02 0 0 1-.27-1.344 1.02 1.02 0 1 1 .915 1.54 6.273 6.273 0 0 1-1.432 2.136 1.785 1.785 0 0 1 .691.306.667.667 0 0 0 .37.168 3.31 3.31 0 0 0 .888-.222 1.02 1.02 0 0 1 1.787-.79v-.005a1.02 1.02 0 0 1-.773 1.683 1.022 1.022 0 0 1-.719-.287 3.935 3.935 0 0 1-1.168.287h-.05a1.313 1.313 0 0 1-.71-.275c-.262-.177-.51-.345-1.402-.12a2.098 2.098 0 0 1-.707.2V24l10.164-5.832V5.832zm4.154 4.904a.352.352 0 0 0-.197.639l.018.01c.163.1.378.053.484-.108v-.002a.352.352 0 0 0-.303-.539zm-4.99 1.928L7.082 9.5v2l4.5-2.668zm8.385.38a.352.352 0 0 0-.295.165v.002a.35.35 0 0 0 .096.473l.013.01a.357.357 0 0 0 .487-.108.352.352 0 0 0-.301-.541zM16.09 8.647a.352.352 0 0 0-.277.163.355.355 0 0 0 .296.54c.482 0 .463-.73-.02-.703zm3.877 2.477a.352.352 0 0 0-.295.164.35.35 0 0 0 .094.475l.015.01a.357.357 0 0 0 .485-.11.352.352 0 0 0-.3-.539zm-4.375 3.594a.352.352 0 0 0-.291.172.35.35 0 0 0-.04.265.352.352 0 1 0 .33-.437zm4.375.789a.352.352 0 0 0-.295.164v.002a.352.352 0 0 0 .094.473l.015.01a.357.357 0 0 0 .485-.108.352.352 0 0 0-.3-.54zm-2.803 2.488v.002a.347.347 0 0 0-.223.084.352.352 0 0 0 .23.62.347.347 0 0 0 .23-.085.348.348 0 0 0 .12-.24.353.353 0 0 0-.35-.38.347.347 0 0 0-.007 0Z" /> + </SvgIcon> + ) +} + +export default MusicBrainz diff --git a/ui/src/icons/Playlist.jsx b/ui/src/icons/Playlist.jsx new file mode 100644 index 0000000..06d1ad5 --- /dev/null +++ b/ui/src/icons/Playlist.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import SvgIcon from '@material-ui/core/SvgIcon' + +const Playlist = (props) => { + return ( + <SvgIcon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}> + <path d="M 16 3 L 16 14.125 C 15.41 13.977 14.732 13.95 14 14.125 C 11.791 14.654 10 16.60975 10 18.46875 C 10 20.32775 11.791 21.404 14 20.875 C 16.149 20.361 17.87575 18.4985 17.96875 16.6875 L 18 16.6875 L 18 7 L 22 7 L 22 3 L 16 3 z M 2 4 L 2 6 L 15 6 L 15 4 L 2 4 z M 2 9 L 2 11 L 15 11 L 15 9 L 2 9 z M 2 14 L 2 16 L 9.75 16 C 10.242 15.218 10.9735 14.526 11.8125 14 L 2 14 z M 2 19 L 2 21 L 10.125 21 C 9.54 20.473 9.1795 19.785 9.0625 19 L 2 19 z" /> + </SvgIcon> + ) +} + +export default Playlist diff --git a/ui/src/icons/SmartPlaylist.jsx b/ui/src/icons/SmartPlaylist.jsx new file mode 100644 index 0000000..c93d62b --- /dev/null +++ b/ui/src/icons/SmartPlaylist.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import SvgIcon from '@material-ui/core/SvgIcon' + +const SmartPlaylist = (props) => { + return ( + <SvgIcon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" {...props}> + <path d="M 5.96875 2 C 5.83675 1.999 5.73275 2.09175 5.71875 2.21875 L 5.5625 3.53125 C 5.2265 3.66225 4.93725 3.81825 4.65625 4.03125 L 3.4375 3.5 C 3.3175 3.447 3.16075 3.48275 3.09375 3.59375 L 2.03125 5.375 C 1.96525 5.485 2.018 5.6115 2.125 5.6875 L 3.1875 6.46875 C 3.1625 6.63875 3.15725 6.79075 3.15625 6.96875 C 3.15625 7.14575 3.1635 7.329 3.1875 7.5 L 2.09375 8.25 C 1.98675 8.324 1.96625 8.48275 2.03125 8.59375 L 3.09375 10.34375 C 3.15875 10.45475 3.28625 10.52175 3.40625 10.46875 L 4.625 9.9375 C 4.905 10.1535 5.2275 10.33475 5.5625 10.46875 L 5.6875 11.75 C 5.7005 11.878 5.8065 12 5.9375 12 L 8.03125 12 C 8.16325 12.001 8.26725 11.90825 8.28125 11.78125 L 8.4375 10.46875 C 8.7735 10.33875 9.06275 10.18175 9.34375 9.96875 L 10.5625 10.5 C 10.6825 10.553 10.83925 10.51725 10.90625 10.40625 L 11.96875 8.625 C 12.03475 8.514 11.982 8.3885 11.875 8.3125 L 10.8125 7.53125 C 10.8375 7.36025 10.84275 7.20825 10.84375 7.03125 C 10.84375 6.85425 10.8365 6.671 10.8125 6.5 L 11.90625 5.75 C 12.01225 5.676 12.03475 5.51825 11.96875 5.40625 L 10.90625 3.625 C 10.84125 3.514 10.71375 3.47825 10.59375 3.53125 L 9.375 4.0625 C 9.095 3.8465 8.7725 3.66525 8.4375 3.53125 L 8.3125 2.25 C 8.3005 2.122 8.1945 2.001 8.0625 2 L 5.96875 2 z M 16 3 L 16 14.125 C 15.41 13.977 14.732 13.95 14 14.125 C 11.791 14.654 10 16.60975 10 18.46875 C 10 20.32775 11.791 21.404 14 20.875 C 16.149 20.361 17.87575 18.4985 17.96875 16.6875 L 18 16.6875 L 18 7 L 22 7 L 22 3 L 16 3 z M 12.28125 4 L 12.8125 4.90625 C 13.0165 5.24925 13.017 5.651 12.875 6 L 15 6 L 15 4 L 12.28125 4 z M 7 5 C 8.131 5.007 9.0655 5.897 9.0625 7 C 9.0595 8.103 8.132 9.006 7 9 C 5.868 8.994 4.9345 8.104 4.9375 7 C 4.9405 5.897 5.868 4.994 7 5 z M 12.875 9 C 12.854 9.049 12.8405 9.11025 12.8125 9.15625 L 11.75 10.90625 C 11.73 10.94025 11.7105 10.969 11.6875 11 L 15 11 L 15 9 L 12.875 9 z M 2 14 L 2 16 L 9.75 16 C 10.242 15.218 10.9735 14.526 11.8125 14 L 2 14 z M 2 19 L 2 21 L 10.125 21 C 9.54 20.474 9.1795 19.785 9.0625 19 L 2 19 z" /> + </SvgIcon> + ) +} + +export default SmartPlaylist diff --git a/ui/src/icons/android-icon-192x192.png b/ui/src/icons/android-icon-192x192.png new file mode 100644 index 0000000..07c10ba Binary files /dev/null and b/ui/src/icons/android-icon-192x192.png differ diff --git a/ui/src/icons/paused-dark.png b/ui/src/icons/paused-dark.png new file mode 100644 index 0000000..2043975 Binary files /dev/null and b/ui/src/icons/paused-dark.png differ diff --git a/ui/src/icons/paused-light.png b/ui/src/icons/paused-light.png new file mode 100644 index 0000000..0eaee8c Binary files /dev/null and b/ui/src/icons/paused-light.png differ diff --git a/ui/src/icons/playing-dark.gif b/ui/src/icons/playing-dark.gif new file mode 100644 index 0000000..b3e8b84 Binary files /dev/null and b/ui/src/icons/playing-dark.gif differ diff --git a/ui/src/icons/playing-light.gif b/ui/src/icons/playing-light.gif new file mode 100644 index 0000000..0b3eb74 Binary files /dev/null and b/ui/src/icons/playing-light.gif differ diff --git a/ui/src/index.css b/ui/src/index.css new file mode 100644 index 0000000..e42e661 --- /dev/null +++ b/ui/src/index.css @@ -0,0 +1,17 @@ +body { + margin: 0; + 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; +} + +.rc-slider { + z-index: 78; +} diff --git a/ui/src/index.jsx b/ui/src/index.jsx new file mode 100644 index 0000000..e2e63d3 --- /dev/null +++ b/ui/src/index.jsx @@ -0,0 +1,10 @@ +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.jsx new file mode 100644 index 0000000..6d5d32d --- /dev/null +++ b/ui/src/layout/ActivityPanel.jsx @@ -0,0 +1,231 @@ +import React, { useState, useEffect } from 'react' +import { useSelector } from 'react-redux' +import { useNotify, useTranslate } from 'react-admin' +import { + Popover, + CircularProgress, + IconButton, + makeStyles, + Tooltip, + Card, + CardContent, + CardActions, + Divider, + Box, + Typography, +} from '@material-ui/core' +import { FiActivity } from 'react-icons/fi' +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 { useInterval } from '../common' +import { useScanElapsedTime } from './useScanElapsedTime' +import { formatDuration, formatShortDuration } from '../utils' +import config from '../config' + +const useStyles = makeStyles((theme) => ({ + wrapper: { + position: 'relative', + color: (props) => (props.up ? null : 'orange'), + }, + progress: { + color: theme.palette.primary.light, + position: 'absolute', + top: 10, + left: 10, + zIndex: 1, + }, + button: { + color: 'inherit', + zIndex: 2, + }, + counterStatus: { + minWidth: '20em', + }, + error: { + color: theme.palette.error.main, + }, + card: { + maxWidth: 'none', + }, + cardContent: { + padding: theme.spacing(2, 3), + }, +})) + +const getUptime = (serverStart) => + formatDuration((Date.now() - serverStart.startTime) / 1000) + +const Uptime = () => { + const serverStart = useSelector((state) => state.activity.serverStart) + const [uptime, setUptime] = useState(getUptime(serverStart)) + useInterval(() => { + setUptime(getUptime(serverStart)) + }, 1000) + return <span>{uptime}</span> +} + +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, + ) + const [acknowledgedError, setAcknowledgedError] = useState(null) + const isErrorVisible = + scanStatus.error && scanStatus.error !== acknowledgedError + const classes = useStyles({ + up: up && (!scanStatus.error || !isErrorVisible), + }) + const translate = useTranslate() + const notify = useNotify() + const [anchorEl, setAnchorEl] = useState(null) + const open = Boolean(anchorEl) + useInitialScanStatus() + + const handleMenuOpen = (event) => { + if (scanStatus.error) { + setAcknowledgedError(scanStatus.error) + } + setAnchorEl(event.currentTarget) + } + + const handleMenuClose = () => setAnchorEl(null) + const triggerScan = (full) => () => subsonic.startScan({ fullScan: full }) + + 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}> + <IconButton className={classes.button} onClick={handleMenuOpen}> + {!up || isErrorVisible ? ( + <BiError data-testid="activity-error-icon" size={'20'} /> + ) : ( + <FiActivity data-testid="activity-ok-icon" size={'20'} /> + )} + </IconButton> + </Tooltip> + {scanStatus.scanning && ( + <CircularProgress size={24} className={classes.progress} /> + )} + <Popover + id="panel-activity" + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + open={open} + onClose={handleMenuClose} + > + <Card className={classes.card}> + <CardContent className={classes.cardContent}> + <Box display="flex" className={classes.counterStatus}> + <Box component="span" flex={2}> + {translate('activity.serverUptime')}: + </Box> + <Box component="span" flex={1}> + {up ? <Uptime /> : translate('activity.serverDown')} + </Box> + </Box> + </CardContent> + <Divider /> + <CardContent className={classes.cardContent}> + <Box display="flex" className={classes.counterStatus}> + <Box component="span" flex={2}> + {translate('activity.totalScanned')}: + </Box> + <Box component="span" flex={1}> + {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> + <Tooltip title={translate('activity.quickScan')}> + <IconButton + onClick={triggerScan(false)} + disabled={scanStatus.scanning} + > + <VscSync /> + </IconButton> + </Tooltip> + <Tooltip title={translate('activity.fullScan')}> + <IconButton + onClick={triggerScan(true)} + disabled={scanStatus.scanning} + > + <GiMagnifyingGlass /> + </IconButton> + </Tooltip> + </CardActions> + </Card> + </Popover> + </div> + ) +} + +export default ActivityPanel diff --git a/ui/src/layout/ActivityPanel.test.jsx b/ui/src/layout/ActivityPanel.test.jsx new file mode 100644 index 0000000..c506fd0 --- /dev/null +++ b/ui/src/layout/ActivityPanel.test.jsx @@ -0,0 +1,61 @@ +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('clears the error icon after opening the panel', () => { + render( + <Provider store={store}> + <ActivityPanel /> + </Provider>, + ) + + const button = screen.getByRole('button') + expect(screen.getByTestId('activity-error-icon')).toBeInTheDocument() + + fireEvent.click(button) + + expect(screen.getByTestId('activity-ok-icon')).toBeInTheDocument() + expect(screen.getByText('Scan failed')).toBeInTheDocument() + }) +}) diff --git a/ui/src/layout/AppBar.jsx b/ui/src/layout/AppBar.jsx new file mode 100644 index 0000000..561701d --- /dev/null +++ b/ui/src/layout/AppBar.jsx @@ -0,0 +1,146 @@ +import React, { createElement, forwardRef, Fragment } from 'react' +import { + AppBar as RAAppBar, + MenuItemLink, + useTranslate, + usePermissions, + getResources, +} from 'react-admin' +import { MdInfo, MdPerson, MdSupervisorAccount } from 'react-icons/md' +import { useSelector } from 'react-redux' +import { makeStyles, MenuItem, ListItemIcon, Divider } from '@material-ui/core' +import ViewListIcon from '@material-ui/icons/ViewList' +import { Dialogs } from '../dialogs/Dialogs' +import { AboutDialog } from '../dialogs' +import PersonalMenu from './PersonalMenu' +import ActivityPanel from './ActivityPanel' +import NowPlayingPanel from './NowPlayingPanel' +import UserMenu from './UserMenu' +import config from '../config' + +const useStyles = makeStyles( + (theme) => ({ + root: { + color: theme.palette.text.secondary, + }, + active: { + color: theme.palette.text.primary, + }, + icon: { minWidth: theme.spacing(5) }, + }), + { + name: 'NDAppBar', + }, +) + +const AboutMenuItem = forwardRef(({ onClick, ...rest }, ref) => { + const classes = useStyles(rest) + const translate = useTranslate() + const [open, setOpen] = React.useState(false) + + 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}> + <MdInfo title={label} size={24} /> + </ListItemIcon> + {label} + </MenuItem> + <AboutDialog onClose={handleClose} open={open} /> + </> + ) +}) + +AboutMenuItem.displayName = 'AboutMenuItem' + +const settingsResources = (resource) => + resource.name !== 'user' && + resource.hasList && + resource.options && + resource.options.subMenu === 'settings' + +const CustomUserMenu = ({ onClick, ...rest }) => { + const translate = useTranslate() + const resources = useSelector(getResources) + const classes = useStyles(rest) + const { permissions } = usePermissions() + + const resourceDefinition = (resourceName) => + resources.find((r) => r?.name === resourceName) + + const renderUserMenuItemLink = () => { + const userResource = resourceDefinition('user') + if (!userResource) { + return null + } + if (permissions !== 'admin') { + if (!config.enableUserEditing) { + return null + } + userResource.icon = MdPerson + } else { + userResource.icon = MdSupervisorAccount + } + return renderSettingsMenuItemLink( + userResource, + permissions !== 'admin' ? localStorage.getItem('userId') : null, + ) + } + + const renderSettingsMenuItemLink = (resource, id) => { + const label = translate(`resources.${resource.name}.name`, { + smart_count: id ? 1 : 2, + }) + const link = id ? `/${resource.name}/${id}` : `/${resource.name}` + return ( + <MenuItemLink + className={classes.root} + activeClassName={classes.active} + key={resource.name} + to={link} + primaryText={label} + leftIcon={ + (resource.icon && createElement(resource.icon, { size: 24 })) || ( + <ViewListIcon /> + ) + } + onClick={onClick} + sidebarIsOpen={true} + /> + ) + } + + return ( + <> + {config.devActivityPanel && + permissions === 'admin' && + config.enableNowPlaying && <NowPlayingPanel />} + {config.devActivityPanel && permissions === 'admin' && <ActivityPanel />} + <UserMenu {...rest}> + <PersonalMenu sidebarIsOpen={true} onClick={onClick} /> + <Divider /> + {renderUserMenuItemLink()} + {resources + .filter(settingsResources) + .map((r) => renderSettingsMenuItemLink(r))} + <Divider /> + <AboutMenuItem /> + </UserMenu> + <Dialogs /> + </> + ) +} + +const AppBar = (props) => ( + <RAAppBar {...props} container={Fragment} userMenu={<CustomUserMenu />} /> +) + +export default AppBar diff --git a/ui/src/layout/AppBar.test.jsx b/ui/src/layout/AppBar.test.jsx new file mode 100644 index 0000000..f39dd75 --- /dev/null +++ b/ui/src/layout/AppBar.test.jsx @@ -0,0 +1,65 @@ +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 + +vi.mock('react-admin', () => ({ + AppBar: ({ userMenu }) => <div data-testid="appbar">{userMenu}</div>, + useTranslate: () => (x) => x, + usePermissions: () => ({ permissions: 'admin' }), + getResources: () => [], +})) + +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 />, +})) + +describe('<AppBar />', () => { + beforeEach(() => { + config.devActivityPanel = true + config.enableNowPlaying = true + 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() + }) +}) diff --git a/ui/src/layout/DynamicMenuIcon.jsx b/ui/src/layout/DynamicMenuIcon.jsx new file mode 100644 index 0000000..e86bff6 --- /dev/null +++ b/ui/src/layout/DynamicMenuIcon.jsx @@ -0,0 +1,23 @@ +import PropTypes from 'prop-types' +import { useLocation } from 'react-router-dom' +import { createElement } from 'react' + +const DynamicMenuIcon = ({ icon, activeIcon, path }) => { + const location = useLocation() + + if (!activeIcon) { + return createElement(icon, { 'data-testid': 'icon' }) + } + + return location.pathname.startsWith('/' + path) + ? createElement(activeIcon, { 'data-testid': 'activeIcon' }) + : createElement(icon, { 'data-testid': 'icon' }) +} + +DynamicMenuIcon.propTypes = { + path: PropTypes.string.isRequired, + icon: PropTypes.object.isRequired, + activeIcon: PropTypes.object, +} + +export default DynamicMenuIcon diff --git a/ui/src/layout/DynamicMenuIcon.test.jsx b/ui/src/layout/DynamicMenuIcon.test.jsx new file mode 100644 index 0000000..51b0ba6 --- /dev/null +++ b/ui/src/layout/DynamicMenuIcon.test.jsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import { cleanup, render, screen } from '@testing-library/react' +import { createMemoryHistory } from 'history' +import { Router } from 'react-router-dom' +import StarIcon from '@material-ui/icons/Star' +import StarBorderIcon from '@material-ui/icons/StarBorder' +import DynamicMenuIcon from './DynamicMenuIcon' + +describe('<DynamicMenuIcon />', () => { + afterEach(cleanup) + + it('renders icon if no activeIcon is specified', () => { + const history = createMemoryHistory() + const route = '/test' + history.push(route) + + render( + <Router history={history}> + <DynamicMenuIcon icon={StarIcon} path={'test'} /> + </Router>, + ) + expect(screen.getByTestId('icon')).not.toBeNull() + }) + + it('renders icon if path does not match the URL', () => { + const history = createMemoryHistory() + const route = '/path' + history.push(route) + + render( + <Router history={history}> + <DynamicMenuIcon + icon={StarIcon} + activeIcon={StarBorderIcon} + path={'otherpath'} + /> + </Router>, + ) + expect(screen.getByTestId('icon')).not.toBeNull() + }) + + it('renders activeIcon if path matches the URL', () => { + const history = createMemoryHistory() + const route = '/path' + history.push(route) + + render( + <Router history={history}> + <DynamicMenuIcon + icon={StarIcon} + activeIcon={StarBorderIcon} + path={'path'} + /> + </Router>, + ) + expect(screen.getByTestId('activeIcon')).not.toBeNull() + }) +}) diff --git a/ui/src/layout/Layout.jsx b/ui/src/layout/Layout.jsx new file mode 100644 index 0000000..e3f13d2 --- /dev/null +++ b/ui/src/layout/Layout.jsx @@ -0,0 +1,39 @@ +import React, { useCallback } from 'react' +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 Menu from './Menu' +import AppBar from './AppBar' +import Notification from './Notification' +import useCurrentTheme from '../themes/useCurrentTheme' + +const useStyles = makeStyles({ + root: { paddingBottom: (props) => (props.addPadding ? '80px' : 0) }, +}) + +const Layout = (props) => { + const theme = useCurrentTheme() + const queue = useSelector((state) => state.player?.queue) + const classes = useStyles({ addPadding: queue.length > 0 }) + const dispatch = useDispatch() + + 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> + ) +} + +export default Layout diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx new file mode 100644 index 0000000..2244f4d --- /dev/null +++ b/ui/src/layout/Login.jsx @@ -0,0 +1,431 @@ +import React, { useState, useCallback, useEffect } from 'react' +import PropTypes from 'prop-types' +import { Field, Form } from 'react-final-form' +import { useDispatch } from 'react-redux' +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' +import Logo from '../icons/android-icon-192x192.png' + +import Notification from './Notification' +import useCurrentTheme from '../themes/useCurrentTheme' +import config from '../config' +import { clearQueue } from '../actions' +import { retrieveTranslation } from '../i18n' +import { INSIGHTS_DOC_URL } from '../consts.js' + +const useStyles = makeStyles( + (theme) => ({ + main: { + display: 'flex', + flexDirection: 'column', + minHeight: '100vh', + alignItems: 'center', + justifyContent: 'flex-start', + background: `url(${config.loginBackgroundURL})`, + backgroundRepeat: 'no-repeat', + backgroundSize: 'cover', + backgroundPosition: 'center', + }, + card: { + minWidth: 300, + marginTop: '6em', + overflow: 'visible', + }, + avatar: { + margin: '1em', + display: 'flex', + justifyContent: 'center', + marginTop: '-3em', + }, + icon: { + backgroundColor: 'transparent', + width: '6.3em', + height: '6.3em', + }, + systemName: { + marginTop: '1em', + display: 'flex', + justifyContent: 'center', + color: '#3f51b5', //theme.palette.grey[500] + }, + welcome: { + marginTop: '1em', + padding: '0 1em 1em 1em', + display: 'flex', + justifyContent: 'center', + flexWrap: 'wrap', + color: '#3f51b5', //theme.palette.grey[500] + }, + form: { + padding: '0 1em 1em 1em', + }, + input: { + marginTop: '1em', + }, + actions: { + padding: '0 1em 1em 1em', + }, + button: {}, + systemNameLink: { + textDecoration: 'none', + }, + message: { + marginTop: '1em', + padding: '0 1em 1em 1em', + textAlign: 'center', + wordBreak: 'break-word', + fontSize: '0.875em', + }, + }), + { name: 'NDLogin' }, +) + +const renderInput = ({ + meta: { touched, error } = {}, + input: { ...inputProps }, + ...props +}) => ( + <TextField + error={!!(touched && error)} + helperText={touched && error} + {...inputProps} + {...props} + fullWidth + /> +) + +const FormLogin = ({ loading, handleSubmit, validate }) => { + const translate = useTranslate() + const classes = useStyles() + + return ( + <Form + onSubmit={handleSubmit} + validate={validate} + render={({ handleSubmit }) => ( + <form onSubmit={handleSubmit} noValidate> + <div className={classes.main}> + <Card className={classes.card}> + <div className={classes.avatar}> + <img src={Logo} className={classes.icon} alt={'logo'} /> + </div> + <div className={classes.systemName}> + <a + href="https://www.navidrome.org" + target="_blank" + rel="noopener noreferrer" + className={classes.systemNameLink} + > + Navidrome + </a> + </div> + {config.welcomeMessage && ( + <div + className={classes.welcome} + dangerouslySetInnerHTML={{ __html: config.welcomeMessage }} + /> + )} + <div className={classes.form}> + <div className={classes.input}> + <Field + autoFocus + name="username" + component={renderInput} + label={translate('ra.auth.username')} + disabled={loading} + spellCheck={false} + /> + </div> + <div className={classes.input}> + <Field + name="password" + component={renderInput} + label={translate('ra.auth.password')} + type="password" + disabled={loading} + /> + </div> + </div> + <CardActions className={classes.actions}> + <Button + variant="contained" + type="submit" + color="primary" + disabled={loading} + className={classes.button} + fullWidth + > + {loading && <CircularProgress size={25} thickness={2} />} + {translate('ra.auth.sign_in')} + </Button> + </CardActions> + </Card> + <Notification /> + </div> + </form> + )} + /> + ) +} + +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() + + return ( + <Form + onSubmit={handleSubmit} + validate={validate} + render={({ handleSubmit }) => ( + <form onSubmit={handleSubmit} noValidate> + <div className={classes.main}> + <Card className={classes.card}> + <div className={classes.avatar}> + <img src={Logo} className={classes.icon} alt={'logo'} /> + </div> + <div className={classes.welcome}> + {translate('ra.auth.welcome1')} + </div> + <div className={classes.welcome}> + {translate('ra.auth.welcome2')} + </div> + <div className={classes.form}> + <div className={classes.input}> + <Field + autoFocus + name="username" + component={renderInput} + label={translate('ra.auth.username')} + disabled={loading} + spellCheck={false} + /> + </div> + <div className={classes.input}> + <Field + name="password" + component={renderInput} + label={translate('ra.auth.password')} + type="password" + disabled={loading} + /> + </div> + <div className={classes.input}> + <Field + name="confirmPassword" + component={renderInput} + label={translate('ra.auth.confirmPassword')} + type="password" + disabled={loading} + /> + </div> + </div> + <CardActions className={classes.actions}> + <Button + variant="contained" + type="submit" + color="primary" + disabled={loading} + className={classes.button} + fullWidth + > + {loading && <CircularProgress size={25} thickness={2} />} + {translate('ra.auth.buttonCreateAdmin')} + </Button> + </CardActions> + <InsightsNotice url={INSIGHTS_DOC_URL} /> + </Card> + <Notification /> + </div> + </form> + )} + /> + ) +} + +const Login = ({ location }) => { + const [loading, setLoading] = useState(false) + const translate = useTranslate() + const notify = useNotify() + const login = useLogin() + const dispatch = useDispatch() + + const handleSubmit = useCallback( + (auth) => { + setLoading(true) + dispatch(clearQueue()) + login(auth, location.state ? location.state.nextPathname : '/').catch( + (error) => { + setLoading(false) + notify( + typeof error === 'string' + ? error + : typeof error === 'undefined' || !error.message + ? 'ra.auth.sign_in_error' + : error.message, + 'warning', + ) + }, + ) + }, + [dispatch, login, notify, setLoading, location], + ) + + const validateLogin = useCallback( + (values) => { + const errors = {} + if (!values.username) { + errors.username = translate('ra.validation.required') + } + if (!values.password) { + errors.password = translate('ra.validation.required') + } + return errors + }, + [translate], + ) + + const validateSignup = useCallback( + (values) => { + const errors = validateLogin(values) + const regex = /^\w+$/g + if (values.username && !values.username.match(regex)) { + errors.username = translate('ra.validation.invalidChars') + } + if (!values.confirmPassword) { + errors.confirmPassword = translate('ra.validation.required') + } + if (values.confirmPassword !== values.password) { + errors.confirmPassword = translate('ra.validation.passwordDoesNotMatch') + } + return errors + }, + [translate, validateLogin], + ) + + if (config.firstTime) { + return ( + <FormSignUp + handleSubmit={handleSubmit} + validate={validateSignup} + loading={loading} + /> + ) + } + return ( + <FormLogin + handleSubmit={handleSubmit} + validate={validateLogin} + loading={loading} + /> + ) +} + +Login.propTypes = { + authProvider: PropTypes.func, + previousRoute: PropTypes.string, +} + +// We need to put the ThemeProvider decoration in another component +// Because otherwise the useStyles() hook used in Login won't get +// 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} /> + </ThemeProvider> + ) +} + +export default LoginWithTheme diff --git a/ui/src/layout/Logout.jsx b/ui/src/layout/Logout.jsx new file mode 100644 index 0000000..e973aa7 --- /dev/null +++ b/ui/src/layout/Logout.jsx @@ -0,0 +1,17 @@ +import React, { useCallback } from 'react' +import { useDispatch } from 'react-redux' +import { Logout as RALogout } from 'react-admin' +import { clearQueue } from '../actions' + +const Logout = (props) => { + const dispatch = useDispatch() + const handleClick = useCallback(() => dispatch(clearQueue()), [dispatch]) + + return ( + <span onClick={handleClick}> + <RALogout {...props} /> + </span> + ) +} + +export default Logout diff --git a/ui/src/layout/Menu.jsx b/ui/src/layout/Menu.jsx new file mode 100644 index 0000000..45f40b2 --- /dev/null +++ b/ui/src/layout/Menu.jsx @@ -0,0 +1,146 @@ +import React, { useState } from 'react' +import { useSelector } from 'react-redux' +import { Divider, makeStyles } from '@material-ui/core' +import clsx from 'clsx' +import { useTranslate, MenuItemLink, getResources } from 'react-admin' +import ViewListIcon from '@material-ui/icons/ViewList' +import AlbumIcon from '@material-ui/icons/Album' +import SubMenu from './SubMenu' +import { humanize, pluralize } from 'inflection' +import albumLists from '../album/albumLists' +import PlaylistsSubMenu from './PlaylistsSubMenu' +import LibrarySelector from '../common/LibrarySelector' +import config from '../config' + +const useStyles = makeStyles((theme) => ({ + root: { + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + transition: theme.transitions.create('width', { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + paddingBottom: (props) => (props.addPadding ? '80px' : '20px'), + }, + open: { + width: 240, + }, + closed: { + width: 55, + }, + active: { + color: theme.palette.text.primary, + fontWeight: 'bold', + }, +})) + +const translatedResourceName = (resource, translate) => + translate(`resources.${resource.name}.name`, { + smart_count: 2, + _: + resource.options && resource.options.label + ? translate(resource.options.label, { + smart_count: 2, + _: resource.options.label, + }) + : humanize(pluralize(resource.name)), + }) + +const Menu = ({ dense = false }) => { + const open = useSelector((state) => state.admin.ui.sidebarOpen) + const translate = useTranslate() + const queue = useSelector((state) => state.player?.queue) + const classes = useStyles({ addPadding: queue.length > 0 }) + const resources = useSelector(getResources) + + // TODO State is not persisted in mobile when you close the sidebar menu. Move to redux? + const [state, setState] = useState({ + menuAlbumList: true, + menuPlaylists: true, + menuSharedPlaylists: true, + }) + + const handleToggle = (menu) => { + setState((state) => ({ ...state, [menu]: !state[menu] })) + } + + const renderResourceMenuItemLink = (resource) => ( + <MenuItemLink + key={resource.name} + to={`/${resource.name}`} + activeClassName={classes.active} + primaryText={translatedResourceName(resource, translate)} + leftIcon={resource.icon || <ViewListIcon />} + sidebarIsOpen={open} + dense={dense} + /> + ) + + const renderAlbumMenuItemLink = (type, al) => { + const resource = resources.find((r) => r.name === 'album') + if (!resource) { + return null + } + + const albumListAddress = `/album/${type}` + + const name = translate(`resources.album.lists.${type || 'default'}`, { + _: translatedResourceName(resource, translate), + }) + + return ( + <MenuItemLink + key={albumListAddress} + to={albumListAddress} + activeClassName={classes.active} + primaryText={name} + leftIcon={al.icon || <ViewListIcon />} + sidebarIsOpen={open} + dense={dense} + exact + /> + ) + } + + const subItems = (subMenu) => (resource) => + resource.hasList && resource.options && resource.options.subMenu === subMenu + + return ( + <div + className={clsx(classes.root, { + [classes.open]: open, + [classes.closed]: !open, + })} + > + {open && <LibrarySelector />} + <SubMenu + handleToggle={() => handleToggle('menuAlbumList')} + isOpen={state.menuAlbumList} + sidebarIsOpen={open} + name="menu.albumList" + icon={<AlbumIcon />} + dense={dense} + > + {Object.keys(albumLists).map((type) => + renderAlbumMenuItemLink(type, albumLists[type]), + )} + </SubMenu> + {resources.filter(subItems(undefined)).map(renderResourceMenuItemLink)} + {config.devSidebarPlaylists && open ? ( + <> + <Divider /> + <PlaylistsSubMenu + state={state} + setState={setState} + sidebarIsOpen={open} + dense={dense} + /> + </> + ) : ( + resources.filter(subItems('playlist')).map(renderResourceMenuItemLink) + )} + </div> + ) +} + +export default Menu diff --git a/ui/src/layout/Notification.jsx b/ui/src/layout/Notification.jsx new file mode 100644 index 0000000..001d3fc --- /dev/null +++ b/ui/src/layout/Notification.jsx @@ -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/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx new file mode 100644 index 0000000..4aaee1b --- /dev/null +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -0,0 +1,353 @@ +import React, { useState, useEffect, useCallback } 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, + ListItemText, + ListItemAvatar, + Avatar, + Badge, + Card, + CardContent, + Typography, + useTheme, + useMediaQuery, +} from '@material-ui/core' +import { FaRegCirclePlay } from 'react-icons/fa6' +import subsonic from '../subsonic' +import { useInterval } from '../common' +import { nowPlayingCountUpdate } from '../actions' +import config from '../config' + +const useStyles = makeStyles((theme) => ({ + button: { color: 'inherit' }, + list: { + width: '30em', + maxHeight: (props) => { + // Calculate height for up to 4 entries before scrolling + const entryHeight = 80 + const maxEntries = Math.min(props.entryCount || 0, 4) + return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' + }, + overflowY: 'auto', + padding: 0, + }, + card: { + padding: 0, + }, + cardContent: { + padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + '&:last-child': { + paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + }, + }, + listItem: { + paddingTop: theme.spacing(0.5), + paddingBottom: theme.spacing(0.5), + paddingLeft: theme.spacing(1), + paddingRight: theme.spacing(1), + }, + avatar: { + width: theme.spacing(6), + height: theme.spacing(6), + cursor: 'pointer', + '&:hover': { + opacity: 0.8, + }, + }, + badge: { + '& .MuiBadge-badge': { + backgroundColor: theme.palette.primary.main, + color: theme.palette.primary.contrastText, + }, + }, + artistLink: { + cursor: 'pointer', + '&:hover': { + textDecoration: 'underline', + }, + }, + primaryText: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + }, +})) + +// NowPlayingButton component - handles the button with badge +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, +} + +// NowPlayingItem component - individual list item +const NowPlayingItem = React.memo( + ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + const classes = useStyles() + const translate = useTranslate() + + return ( + <ListItem key={nowPlayingEntry.playerId} className={classes.listItem}> + <ListItemAvatar> + <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> + </ListItemAvatar> + <ListItemText + primary={ + <div className={classes.primaryText}> + {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( + <Link + to={getArtistLink( + nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId, + )} + className={classes.artistLink} + onClick={onLinkClick} + > + {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} + </Link> + ) : ( + <span> + {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} + </span> + )} +  - {nowPlayingEntry.title} + </div> + } + secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} + /> + </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, + minutesAgo: PropTypes.number.isRequired, + album: PropTypes.string, + }).isRequired, + onLinkClick: PropTypes.func.isRequired, + getArtistLink: PropTypes.func.isRequired, +} + +// NowPlayingList component - handles the popover content +const NowPlayingList = React.memo( + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + 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.playerId} + nowPlayingEntry={nowPlayingEntry} + onLinkClick={onLinkClick} + getArtistLink={getArtistLink} + /> + ))} + </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, +} + +// Main NowPlayingPanel component +const NowPlayingPanel = () => { + const dispatch = useDispatch() + const count = useSelector((state) => state.activity.nowPlayingCount) + 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 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 fetchList = useCallback( + () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + setEntries(nowPlayingEntries) + // Also update the count in Redux store + dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, + }) + }), + [dispatch, notify], + ) + + // Initialize count and entries on mount, and refresh on server/stream changes + useEffect(() => { + if (serverUp) fetchList() + }, [fetchList, serverUp, streamReconnected]) + + // Refresh when count changes from WebSocket events (if panel is open) + useEffect(() => { + if (open && serverUp) fetchList() + }, [count, open, fetchList, serverUp]) + + // 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} + 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 new file mode 100644 index 0000000..4dd5dac --- /dev/null +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -0,0 +1,367 @@ +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, + ) + } + + beforeEach(() => { + 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>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + 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>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect( + screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), + ).toBeInTheDocument() + }) + }) + + it('handles entries without player name', async () => { + subsonic.getNowPlaying.mockResolvedValueOnce({ + 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>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + fireEvent.click(screen.getByRole('button')) + await waitFor(() => { + expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + }) + }) + + it('shows empty message when no entries', async () => { + subsonic.getNowPlaying.mockResolvedValueOnce({ + json: { + 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, + }, + }) + const store = createMockStore({ nowPlayingCount: 0 }) + render( + <Provider store={store}> + <NowPlayingPanel /> + </Provider>, + ) + + // Wait for initial fetch + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + 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>, + ) + + // Wait for initial fetch to complete + await waitFor(() => { + expect(subsonic.getNowPlaying).toHaveBeenCalled() + }) + + // 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', () => { + 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>, + ) + + // Should only make one call despite both serverUp and streamReconnected changing + expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + }) + + 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.jsx new file mode 100644 index 0000000..12f8bee --- /dev/null +++ b/ui/src/layout/PersonalMenu.jsx @@ -0,0 +1,31 @@ +import React, { forwardRef } from 'react' +import { MenuItemLink, useTranslate } from 'react-admin' +import { MdTune } from 'react-icons/md' +import { makeStyles } from '@material-ui/core' + +const useStyles = makeStyles((theme) => ({ + menuItem: { + color: theme.palette.text.secondary, + }, +})) + +const PersonalMenu = forwardRef(({ onClick, sidebarIsOpen, dense }, ref) => { + const translate = useTranslate() + const classes = useStyles() + return ( + <MenuItemLink + ref={ref} + to="/personal" + primaryText={translate('menu.personal.name')} + leftIcon={<MdTune size={24} />} + onClick={onClick} + className={classes.menuItem} + sidebarIsOpen={sidebarIsOpen} + dense={dense} + /> + ) +}) + +PersonalMenu.displayName = 'PersonalMenu' + +export default PersonalMenu diff --git a/ui/src/layout/PlaylistsSubMenu.jsx b/ui/src/layout/PlaylistsSubMenu.jsx new file mode 100644 index 0000000..a9f70b8 --- /dev/null +++ b/ui/src/layout/PlaylistsSubMenu.jsx @@ -0,0 +1,129 @@ +import React, { useCallback } from 'react' +import { + MenuItemLink, + useDataProvider, + useNotify, + useQueryWithStore, +} 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 { BiCog } from 'react-icons/bi' +import { useDrop } from 'react-dnd' +import SubMenu from './SubMenu' +import { canChangeTracks } from '../common' +import { DraggableTypes } from '../consts' +import config from '../config' + +const PlaylistMenuItemLink = ({ pls, sidebarIsOpen }) => { + const dataProvider = useDataProvider() + const notify = useNotify() + + const [, dropRef] = useDrop(() => ({ + accept: canChangeTracks(pls) ? DraggableTypes.ALL : [], + drop: (item) => + dataProvider + .addToPlaylist(pls.id, item) + .then((res) => { + notify('message.songsAddedToPlaylist', 'info', { + smart_count: res.data?.added, + }) + }) + .catch(() => { + notify('ra.page.error', 'warning') + }), + })) + + return ( + <MenuItemLink + to={`/playlist/${pls.id}/show`} + primaryText={ + <Typography variant="inherit" noWrap ref={dropRef}> + {pls.name} + </Typography> + } + sidebarIsOpen={sidebarIsOpen} + dense={false} + /> + ) +} + +const PlaylistsSubMenu = ({ state, setState, sidebarIsOpen, dense }) => { + const history = useHistory() + const { data, loaded } = useQueryWithStore({ + type: 'getList', + resource: 'playlist', + payload: { + pagination: { + page: 0, + perPage: config.maxSidebarPlaylists, + }, + sort: { field: 'name' }, + }, + }) + + const handleToggle = (menu) => { + setState((state) => ({ ...state, [menu]: !state[menu] })) + } + + const renderPlaylistMenuItemLink = (pls) => ( + <PlaylistMenuItemLink + pls={pls} + sidebarIsOpen={sidebarIsOpen} + key={pls.id} + /> + ) + + const userId = localStorage.getItem('userId') + const myPlaylists = [] + const sharedPlaylists = [] + + if (loaded && data) { + const allPlaylists = Object.keys(data).map((id) => data[id]) + + allPlaylists.forEach((pls) => { + if (userId === pls.ownerId) { + myPlaylists.push(pls) + } else { + sharedPlaylists.push(pls) + } + }) + } + + const onPlaylistConfig = useCallback( + () => history.push('/playlist'), + [history], + ) + + return ( + <> + <SubMenu + handleToggle={() => handleToggle('menuPlaylists')} + isOpen={state.menuPlaylists} + sidebarIsOpen={sidebarIsOpen} + name={'menu.playlists'} + icon={<QueueMusicIcon />} + dense={dense} + actionIcon={<BiCog />} + onAction={onPlaylistConfig} + > + {myPlaylists.map(renderPlaylistMenuItemLink)} + </SubMenu> + {sharedPlaylists?.length > 0 && ( + <SubMenu + handleToggle={() => handleToggle('menuSharedPlaylists')} + isOpen={state.menuSharedPlaylists} + sidebarIsOpen={sidebarIsOpen} + name={'menu.sharedPlaylists'} + icon={<QueueMusicOutlinedIcon />} + dense={dense} + > + {sharedPlaylists.map(renderPlaylistMenuItemLink)} + </SubMenu> + )} + </> + ) +} + +export default PlaylistsSubMenu diff --git a/ui/src/layout/SubMenu.jsx b/ui/src/layout/SubMenu.jsx new file mode 100644 index 0000000..418f4c6 --- /dev/null +++ b/ui/src/layout/SubMenu.jsx @@ -0,0 +1,130 @@ +import React, { Fragment } from 'react' +import { useDispatch } from 'react-redux' +import ExpandMore from '@material-ui/icons/ExpandMore' +import ArrowRightOutlined from '@material-ui/icons/ArrowRightOutlined' +import List from '@material-ui/core/List' +import MenuItem from '@material-ui/core/MenuItem' +import ListItemIcon from '@material-ui/core/ListItemIcon' +import Typography from '@material-ui/core/Typography' +import Collapse from '@material-ui/core/Collapse' +import Tooltip from '@material-ui/core/Tooltip' +import { makeStyles } from '@material-ui/core/styles' +import { setSidebarVisibility, useTranslate } from 'react-admin' +import { IconButton, useMediaQuery } from '@material-ui/core' + +const useStyles = makeStyles( + (theme) => ({ + icon: { minWidth: theme.spacing(5) }, + sidebarIsOpen: { + '& a': { + transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms', + paddingLeft: theme.spacing(4), + }, + }, + sidebarIsClosed: { + '& a': { + transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms', + paddingLeft: theme.spacing(2), + }, + }, + actionIcon: { + opacity: 0, + }, + menuHeader: { + width: '100%', + }, + headerWrapper: { + display: 'flex', + '&:hover $actionIcon': { + opacity: 1, + }, + }, + }), + { + name: 'NDSubMenu', + }, +) + +const SubMenu = ({ + handleToggle, + sidebarIsOpen, + isOpen, + name, + icon, + children, + dense, + onAction, + actionIcon, +}) => { + const translate = useTranslate() + const classes = useStyles() + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const isSmall = useMediaQuery((theme) => theme.breakpoints.down('sm')) + const dispatch = useDispatch() + + const handleOnClick = (e) => { + e.stopPropagation() + onAction(e) + if (isSmall) { + dispatch(setSidebarVisibility(false)) + } + } + + const header = ( + <div className={classes.headerWrapper}> + <MenuItem + dense={dense} + button + className={classes.menuHeader} + onClick={handleToggle} + > + <ListItemIcon className={classes.icon}> + {isOpen ? <ExpandMore /> : icon} + </ListItemIcon> + <Typography variant="inherit" color="textSecondary"> + {translate(name)} + </Typography> + {onAction && sidebarIsOpen && ( + <IconButton + size={'small'} + className={isDesktop ? classes.actionIcon : null} + onClick={handleOnClick} + > + {actionIcon} + </IconButton> + )} + </MenuItem> + </div> + ) + + return ( + <Fragment> + {sidebarIsOpen || isOpen ? ( + header + ) : ( + <Tooltip title={translate(name)} placement="right"> + {header} + </Tooltip> + )} + <Collapse in={isOpen} timeout="auto" unmountOnExit> + <List + dense={dense} + component="div" + disablePadding + className={ + sidebarIsOpen ? classes.sidebarIsOpen : classes.sidebarIsClosed + } + > + {children} + </List> + </Collapse> + </Fragment> + ) +} + +SubMenu.defaultProps = { + action: null, + actionIcon: <ArrowRightOutlined fontSize={'small'} />, +} + +export default SubMenu diff --git a/ui/src/layout/Themes.jsx b/ui/src/layout/Themes.jsx new file mode 100644 index 0000000..e69de29 diff --git a/ui/src/layout/UserMenu.jsx b/ui/src/layout/UserMenu.jsx new file mode 100644 index 0000000..c7a3dea --- /dev/null +++ b/ui/src/layout/UserMenu.jsx @@ -0,0 +1,141 @@ +import * as React from 'react' +import { + Children, + cloneElement, + isValidElement, + useEffect, + useState, +} from 'react' +import PropTypes from 'prop-types' +import { useTranslate, useGetIdentity } from 'react-admin' +import { + Tooltip, + IconButton, + Popover, + MenuList, + Avatar, + Card, + CardContent, + Divider, + Typography, +} from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import AccountCircle from '@material-ui/icons/AccountCircle' +import config from '../config' +import authProvider from '../authProvider' +import { startEventStream } from '../eventStream' +import { useDispatch } from 'react-redux' + +const useStyles = makeStyles((theme) => ({ + user: {}, + avatar: { + width: theme.spacing(4), + height: theme.spacing(4), + }, + username: { + maxWidth: '11em', + marginTop: '-0.7em', + marginBottom: '-1em', + }, + usernameWrap: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +})) + +const UserMenu = (props) => { + const [anchorEl, setAnchorEl] = useState(null) + const translate = useTranslate() + const { loaded, identity } = useGetIdentity() + const classes = useStyles(props) + const dispatch = useDispatch() + + const { children, label, icon, logout } = props + + useEffect(() => { + if (config.devActivityPanel) { + authProvider + .checkAuth() + .then(() => startEventStream(dispatch)) + .catch(() => {}) + } + }, [dispatch]) + + if (!logout && !children) return null + const open = Boolean(anchorEl) + + const handleMenu = (event) => setAnchorEl(event.currentTarget) + const handleClose = () => setAnchorEl(null) + + return ( + <div className={classes.user}> + <Tooltip title={label && translate(label, { _: label })}> + <IconButton + 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 + className={classes.avatar} + src={identity.avatar} + alt={identity.fullName} + /> + ) : ( + icon + )} + </IconButton> + </Tooltip> + <Popover + id="menu-appbar" + anchorEl={anchorEl} + anchorOrigin={{ + vertical: 'bottom', + horizontal: 'right', + }} + transformOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + open={open} + onClose={handleClose} + > + <MenuList> + {loaded && ( + <Card elevation={0} className={classes.username}> + <CardContent className={classes.usernameWrap}> + <Typography variant={'button'}>{identity.fullName}</Typography> + </CardContent> + </Card> + )} + <Divider /> + {Children.map(children, (menuItem) => + isValidElement(menuItem) + ? cloneElement(menuItem, { + onClick: handleClose, + }) + : null, + )} + {!config.auth && logout} + </MenuList> + </Popover> + </div> + ) +} + +UserMenu.propTypes = { + children: PropTypes.node, + label: PropTypes.string.isRequired, + logout: PropTypes.element, +} + +UserMenu.defaultProps = { + label: 'menu.settings', + icon: <AccountCircle />, +} + +export default UserMenu diff --git a/ui/src/layout/index.js b/ui/src/layout/index.js new file mode 100644 index 0000000..c04e6f3 --- /dev/null +++ b/ui/src/layout/index.js @@ -0,0 +1,5 @@ +import Login from './Login' +import Logout from './Logout' +import Layout from './Layout' + +export { Layout, Login, Logout } diff --git a/ui/src/layout/useInitialScanStatus.jsx b/ui/src/layout/useInitialScanStatus.jsx new file mode 100644 index 0000000..913f68e --- /dev/null +++ b/ui/src/layout/useInitialScanStatus.jsx @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..af084a1 --- /dev/null +++ b/ui/src/layout/useScanElapsedTime.jsx @@ -0,0 +1,26 @@ +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 new file mode 100644 index 0000000..1e94850 --- /dev/null +++ b/ui/src/layout/useScanElapsedTime.test.jsx @@ -0,0 +1,135 @@ +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 new file mode 100644 index 0000000..8d9ff6e --- /dev/null +++ b/ui/src/library/DeleteLibraryButton.jsx @@ -0,0 +1,80 @@ +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 new file mode 100644 index 0000000..0e69964 --- /dev/null +++ b/ui/src/library/LibraryCreate.jsx @@ -0,0 +1,84 @@ +import React, { useCallback } from 'react' +import { + Create, + SimpleForm, + TextInput, + BooleanInput, + required, + useTranslate, + useMutation, + useNotify, + useRedirect, +} from 'react-admin' +import { Title } from '../common' + +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'}> + <TextInput source="name" validate={[required()]} /> + <TextInput source="path" validate={[required()]} fullWidth /> + <BooleanInput source="defaultNewUsers" /> + </SimpleForm> + </Create> + ) +} + +export default LibraryCreate diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx new file mode 100644 index 0000000..7e89c89 --- /dev/null +++ b/ui/src/library/LibraryEdit.jsx @@ -0,0 +1,273 @@ +import React, { useCallback } from 'react' +import { + Edit, + FormWithRedirect, + TextInput, + BooleanInput, + required, + SaveButton, + DateField, + 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 { Title } from '../common' +import { formatBytes, formatDuration2, formatNumber } from '../utils/index.js' + +const useStyles = makeStyles({ + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}) + +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> +) + +const LibraryEdit = (props) => { + const translate = useTranslate() + 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) => ( + <form onSubmit={formProps.handleSubmit}> + <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" /> + + {/* Statistics - Two Column Layout */} + <Typography variant="h6" gutterBottom> + {translate('resources.library.sections.statistics')} + </Typography> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalSongs'} + label={translate('resources.library.fields.totalSongs')} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalAlbums'} + label={translate( + 'resources.library.fields.totalAlbums', + )} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalArtists'} + label={translate( + 'resources.library.fields.totalArtists', + )} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalSize'} + label={translate('resources.library.fields.totalSize')} + format={(v) => formatBytes(v, 2)} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + <Box display="flex"> + <Box flex={1} mr="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalDuration'} + label={translate( + 'resources.library.fields.totalDuration', + )} + format={formatDuration2} + fullWidth + variant="outlined" + /> + </Box> + <Box flex={1} ml="0.5em"> + <TextInput + InputProps={{ readOnly: true }} + resource={'library'} + source={'totalMissingFiles'} + label={translate( + 'resources.library.fields.totalMissingFiles', + )} + fullWidth + variant="outlined" + /> + </Box> + </Box> + + {/* Timestamps Section */} + <Box mb="1em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.lastScanAt')} + </Typography> + <DateField + variant="body1" + source="lastScanAt" + showTime + record={formProps.record} + /> + </Box> + + <Box mb="1em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.updatedAt')} + </Typography> + <DateField + variant="body1" + source="updatedAt" + showTime + record={formProps.record} + /> + </Box> + + <Box mb="2em"> + <Typography + variant="body2" + color="textSecondary" + gutterBottom + > + {translate('resources.library.fields.createdAt')} + </Typography> + <DateField + variant="body1" + source="createdAt" + showTime + record={formProps.record} + /> + </Box> + </Box> + </Box> + </Box> + + <CustomToolbar + handleSubmitWithRedirect={formProps.handleSubmitWithRedirect} + pristine={formProps.pristine} + saving={formProps.saving} + record={formProps.record} + showDelete={canDelete} + /> + </form> + )} + /> + </Edit> + ) +} + +export default LibraryEdit diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx new file mode 100644 index 0000000..aa12948 --- /dev/null +++ b/ui/src/library/LibraryList.jsx @@ -0,0 +1,56 @@ +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')) + 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" /> + <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 new file mode 100644 index 0000000..f4d0913 --- /dev/null +++ b/ui/src/library/LibraryListActions.jsx @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..8862a4f --- /dev/null +++ b/ui/src/library/LibraryListBulkActions.jsx @@ -0,0 +1,11 @@ +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 new file mode 100644 index 0000000..50d90e6 --- /dev/null +++ b/ui/src/library/LibraryScanButton.jsx @@ -0,0 +1,77 @@ +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 SyncIcon from '@material-ui/icons/Sync' +import CachedIcon from '@material-ui/icons/Cached' +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 ? <CachedIcon /> : <SyncIcon /> + + 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/index.js b/ui/src/library/index.js new file mode 100644 index 0000000..3a8b71b --- /dev/null +++ b/ui/src/library/index.js @@ -0,0 +1,11 @@ +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/missing/DeleteMissingFilesButton.jsx b/ui/src/missing/DeleteMissingFilesButton.jsx new file mode 100644 index 0000000..f02bb81 --- /dev/null +++ b/ui/src/missing/DeleteMissingFilesButton.jsx @@ -0,0 +1,90 @@ +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 new file mode 100644 index 0000000..cafae9a --- /dev/null +++ b/ui/src/missing/DeleteMissingFilesButton.test.jsx @@ -0,0 +1,42 @@ +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 new file mode 100644 index 0000000..87d9f62 --- /dev/null +++ b/ui/src/missing/MissingFilesList.jsx @@ -0,0 +1,79 @@ +import { List, SizeField, useResourceRefresh } from '../common/index' +import { + Datagrid, + DateField, + TextField, + downloadCSV, + Pagination, + 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 MissingPagination = (props) => ( + <Pagination rowsPerPageOptions={[50, 100, 200]} {...props} /> +) + +const MissingFilesList = (props) => { + useResourceRefresh('song') + return ( + <List + {...props} + sort={{ field: 'updated_at', order: 'DESC' }} + exporter={exporter} + actions={<MissingListActions />} + filters={<MissingFilesFilter />} + bulkActionButtons={<BulkActionButtons />} + perPage={50} + 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 new file mode 100644 index 0000000..4bbf771 --- /dev/null +++ b/ui/src/missing/MissingListActions.jsx @@ -0,0 +1,12 @@ +import React from 'react' +import { TopToolbar, ExportButton } from 'react-admin' +import DeleteMissingFilesButton from './DeleteMissingFilesButton.jsx' + +const MissingListActions = (props) => ( + <TopToolbar {...props}> + <ExportButton /> + <DeleteMissingFilesButton deleteAll /> + </TopToolbar> +) + +export default MissingListActions diff --git a/ui/src/missing/index.js b/ui/src/missing/index.js new file mode 100644 index 0000000..471dcd1 --- /dev/null +++ b/ui/src/missing/index.js @@ -0,0 +1,6 @@ +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.jsx new file mode 100644 index 0000000..d95eec1 --- /dev/null +++ b/ui/src/personal/HelpMsg.jsx @@ -0,0 +1,8 @@ +import HelpOutlineIcon from '@material-ui/icons/HelpOutline' + +export const HelpMsg = ({ caption }) => ( + <> + <HelpOutlineIcon /> +    {caption} + </> +) diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx new file mode 100644 index 0000000..67018d2 --- /dev/null +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -0,0 +1,134 @@ +import { useEffect, useRef, useState } from 'react' +import { useNotify, useTranslate } from 'react-admin' +import { + FormControl, + FormControlLabel, + FormHelperText, + LinearProgress, + Switch, + Tooltip, +} from '@material-ui/core' +import { useInterval } from '../common' +import { baseUrl, openInNewTab } from '../utils' +import { httpClient } from '../dataProvider' + +const Progress = (props) => { + const { setLinked, setCheckingLink, apiKey } = 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=${apiKey}&cb=${callbackUrl}`, + ) + }, [apiKey]) + + const endChecking = (success) => { + linkCheckDelay = null + setCheckingLink(false) + if (success) { + notify('message.lastfmLinkSuccess', 'success') + } else { + notify('message.lastfmLinkFailure', 'warning') + } + setLinked(success) + } + + useInterval(() => { + httpClient('/api/lastfm/link') + .then((response) => { + let result = false + if (response.json.status === true) { + result = true + endChecking(true) + } + return result + }) + .then((result) => { + if (!result && openedTab.current?.closed === true) { + endChecking(false) + result = true + } + return result + }) + .then((result) => { + if (!result && --linkChecks === 0) { + endChecking(false) + } + }) + .catch(() => { + endChecking(false) + }) + }, linkCheckDelay) + + return <LinearProgress /> +} + +export const LastfmScrobbleToggle = (props) => { + const notify = useNotify() + const translate = useTranslate() + const [linked, setLinked] = useState(null) + const [checkingLink, setCheckingLink] = useState(false) + const [apiKey, setApiKey] = useState(false) + + useEffect(() => { + httpClient('/api/lastfm/link') + .then((response) => { + setLinked(response.json.status === true) + setApiKey(response.json.apiKey) + }) + .catch(() => { + setLinked(false) + }) + }, [setLinked, setApiKey]) + + const toggleScrobble = () => { + if (!linked) { + setCheckingLink(true) + } else { + httpClient('/api/lastfm/link', { method: 'DELETE' }) + .then(() => { + setLinked(false) + notify('message.lastfmUnlinkSuccess', 'success') + }) + .catch(() => notify('message.lastfmUnlinkFailure', 'warning')) + } + } + + return ( + <FormControl> + <FormControlLabel + control={ + <Switch + id={'lastfm'} + color="primary" + checked={linked || checkingLink} + disabled={!apiKey || linked === null || checkingLink} + onChange={toggleScrobble} + /> + } + label={ + <span>{translate('menu.personal.options.lastfmScrobbling')}</span> + } + /> + {checkingLink && ( + <Progress + setLinked={setLinked} + setCheckingLink={setCheckingLink} + apiKey={apiKey} + /> + )} + {!apiKey && ( + <FormHelperText id="scrobble-lastfm-disabled-helper-text"> + {translate('menu.personal.options.lastfmNotConfigured')} + </FormHelperText> + )} + </FormControl> + ) +} diff --git a/ui/src/personal/ListenBrainzScrobbleToggle.jsx b/ui/src/personal/ListenBrainzScrobbleToggle.jsx new file mode 100644 index 0000000..7270352 --- /dev/null +++ b/ui/src/personal/ListenBrainzScrobbleToggle.jsx @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react' +import { useNotify, useTranslate } from 'react-admin' +import { FormControl, FormControlLabel, Switch } from '@material-ui/core' +import { httpClient } from '../dataProvider' +import { ListenBrainzTokenDialog } from '../dialogs' +import { useDispatch } from 'react-redux' +import { openListenBrainzTokenDialog } from '../actions' + +export const ListenBrainzScrobbleToggle = () => { + const dispatch = useDispatch() + const notify = useNotify() + const translate = useTranslate() + const [linked, setLinked] = useState(null) + + const toggleScrobble = () => { + if (linked) { + httpClient('/api/listenbrainz/link', { method: 'DELETE' }) + .then(() => { + setLinked(false) + notify('message.listenBrainzUnlinkSuccess', 'success') + }) + .catch(() => notify('message.listenBrainzUnlinkFailure', 'warning')) + } else { + dispatch(openListenBrainzTokenDialog()) + } + } + + useEffect(() => { + httpClient('/api/listenbrainz/link') + .then((response) => { + setLinked(response.json.status === true) + }) + .catch(() => { + setLinked(false) + }) + }, []) + + return ( + <> + <FormControl> + <FormControlLabel + control={ + <Switch + id={'listenbrainz'} + color="primary" + checked={linked === true} + disabled={linked === null} + onChange={toggleScrobble} + /> + } + label={ + <span> + {translate('menu.personal.options.listenBrainzScrobbling')} + </span> + } + /> + </FormControl> + <ListenBrainzTokenDialog setLinked={setLinked} /> + </> + ) +} diff --git a/ui/src/personal/NotificationsToggle.jsx b/ui/src/personal/NotificationsToggle.jsx new file mode 100644 index 0000000..f9b424f --- /dev/null +++ b/ui/src/personal/NotificationsToggle.jsx @@ -0,0 +1,64 @@ +import { useNotify, useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { setNotificationsState } from '../actions' +import { + FormControl, + FormControlLabel, + FormHelperText, + Switch, +} from '@material-ui/core' + +export const NotificationsToggle = () => { + const translate = useTranslate() + const dispatch = useDispatch() + const notify = useNotify() + const currentSetting = useSelector((state) => state.settings.notifications) + const notAvailable = !('Notification' in window) || !window.isSecureContext + + if ( + (currentSetting && Notification.permission !== 'granted') || + notAvailable + ) { + dispatch(setNotificationsState(false)) + } + + const toggleNotifications = (event) => { + if (currentSetting && !event.target.checked) { + dispatch(setNotificationsState(false)) + } else { + if (Notification.permission === 'denied') { + notify(translate('message.notifications_blocked'), 'warning') + } else { + Notification.requestPermission().then((permission) => { + dispatch(setNotificationsState(permission === 'granted')) + }) + } + } + } + + return ( + <FormControl> + <FormControlLabel + control={ + <Switch + id={'notifications'} + color="primary" + checked={currentSetting} + disabled={notAvailable} + onChange={toggleNotifications} + /> + } + label={ + <span> + {translate('menu.personal.options.desktop_notifications')} + </span> + } + /> + {notAvailable && ( + <FormHelperText id="notifications-disabled-helper-text"> + {translate('message.notifications_not_available')} + </FormHelperText> + )} + </FormControl> + ) +} diff --git a/ui/src/personal/Personal.jsx b/ui/src/personal/Personal.jsx new file mode 100644 index 0000000..84f9b63 --- /dev/null +++ b/ui/src/personal/Personal.jsx @@ -0,0 +1,37 @@ +import { SimpleForm, Title, useTranslate } from 'react-admin' +import { Card } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { SelectLanguage } from './SelectLanguage' +import { SelectTheme } from './SelectTheme' +import { SelectDefaultView } from './SelectDefaultView' +import { NotificationsToggle } from './NotificationsToggle' +import { LastfmScrobbleToggle } from './LastfmScrobbleToggle' +import { ListenBrainzScrobbleToggle } from './ListenBrainzScrobbleToggle' +import config from '../config' +import { ReplayGainToggle } from './ReplayGainToggle' + +const useStyles = makeStyles({ + root: { marginTop: '1em' }, +}) + +const Personal = () => { + const translate = useTranslate() + const classes = useStyles() + + return ( + <Card className={classes.root}> + <Title title={'Navidrome - ' + translate('menu.personal.name')} /> + <SimpleForm toolbar={null} variant={'outlined'}> + <SelectTheme /> + <SelectLanguage /> + <SelectDefaultView /> + {config.enableReplayGain && <ReplayGainToggle />} + <NotificationsToggle /> + {config.lastFMEnabled && <LastfmScrobbleToggle />} + {config.listenBrainzEnabled && <ListenBrainzScrobbleToggle />} + </SimpleForm> + </Card> + ) +} + +export default Personal diff --git a/ui/src/personal/ReplayGainToggle.jsx b/ui/src/personal/ReplayGainToggle.jsx new file mode 100644 index 0000000..a3fc4ba --- /dev/null +++ b/ui/src/personal/ReplayGainToggle.jsx @@ -0,0 +1,44 @@ +import { NumberInput, SelectInput, useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { changeGain, changePreamp } from '../actions' + +export const ReplayGainToggle = (props) => { + const translate = useTranslate() + const dispatch = useDispatch() + const gainInfo = useSelector((state) => state.replayGain) + + return ( + <> + <SelectInput + {...props} + fullWidth + source="replayGain" + label={translate('menu.personal.options.replaygain')} + choices={[ + { id: 'none', name: 'menu.personal.options.gain.none' }, + { id: 'album', name: 'menu.personal.options.gain.album' }, + { id: 'track', name: 'menu.personal.options.gain.track' }, + ]} + defaultValue={gainInfo.gainMode} + onChange={(event) => { + dispatch(changeGain(event.target.value)) + }} + /> + <br /> + {gainInfo.gainMode !== 'none' && ( + <NumberInput + {...props} + source="preAmp" + label={translate('menu.personal.options.preAmp')} + defaultValue={gainInfo.preAmp} + step={0.5} + min={-15} + max={15} + onChange={(event) => { + dispatch(changePreamp(event.target.value)) + }} + /> + )} + </> + ) +} diff --git a/ui/src/personal/SelectDefaultView.jsx b/ui/src/personal/SelectDefaultView.jsx new file mode 100644 index 0000000..71c8730 --- /dev/null +++ b/ui/src/personal/SelectDefaultView.jsx @@ -0,0 +1,25 @@ +import { SelectInput, useTranslate } from 'react-admin' +import albumLists, { defaultAlbumList } from '../album/albumLists' + +export const SelectDefaultView = (props) => { + const translate = useTranslate() + const current = localStorage.getItem('defaultView') || defaultAlbumList + const choices = Object.keys(albumLists).map((type) => ({ + id: type, + name: translate(`resources.album.lists.${type}`), + })) + + return ( + <SelectInput + {...props} + source="defaultView" + label={translate('menu.personal.options.defaultView')} + defaultValue={current} + choices={choices} + translateChoice={false} + onChange={(event) => { + localStorage.setItem('defaultView', event.target.value) + }} + /> + ) +} diff --git a/ui/src/personal/SelectLanguage.jsx b/ui/src/personal/SelectLanguage.jsx new file mode 100644 index 0000000..49b879f --- /dev/null +++ b/ui/src/personal/SelectLanguage.jsx @@ -0,0 +1,39 @@ +import { SelectInput, useLocale, useSetLocale, useTranslate } from 'react-admin' +import { useGetLanguageChoices } from '../i18n' +import { HelpMsg } from './HelpMsg' +import { docsUrl, openInNewTab } from '../utils' + +const helpKey = '_help' + +export const SelectLanguage = (props) => { + const translate = useTranslate() + const setLocale = useSetLocale() + const locale = useLocale() + const { choices } = useGetLanguageChoices() + + choices.push({ + id: helpKey, + name: <HelpMsg caption={'Help to translate'} />, + }) + + return ( + <SelectInput + {...props} + source="language" + label={translate('menu.personal.options.language')} + defaultValue={locale} + choices={choices} + translateChoice={false} + onChange={(event) => { + if (event.target.value === helpKey) { + openInNewTab(docsUrl('/docs/developers/translations/')) + return + } + setLocale(event.target.value).then(() => { + localStorage.setItem('locale', event.target.value) + document.documentElement.lang = event.target.value + }) + }} + /> + ) +} diff --git a/ui/src/personal/SelectTheme.jsx b/ui/src/personal/SelectTheme.jsx new file mode 100644 index 0000000..6ec39b0 --- /dev/null +++ b/ui/src/personal/SelectTheme.jsx @@ -0,0 +1,47 @@ +import { SelectInput, useTranslate } from 'react-admin' +import { useDispatch, useSelector } from 'react-redux' +import { AUTO_THEME_ID } from '../consts' +import themes from '../themes' +import { HelpMsg } from './HelpMsg' +import { docsUrl, openInNewTab } from '../utils' +import { changeTheme } from '../actions' + +const helpKey = '_help' + +export const SelectTheme = (props) => { + const translate = useTranslate() + const dispatch = useDispatch() + const currentTheme = useSelector((state) => state.theme) + const themeChoices = [ + { + id: AUTO_THEME_ID, + name: 'Auto', + }, + ] + themeChoices.push( + ...Object.keys(themes).map((key) => { + return { id: key, name: themes[key].themeName } + }), + ) + themeChoices.push({ + id: helpKey, + name: <HelpMsg caption={'Create your own'} />, + }) + return ( + <SelectInput + {...props} + source="theme" + label={translate('menu.personal.options.theme')} + defaultValue={currentTheme} + translateChoice={false} + choices={themeChoices} + onChange={(event) => { + if (event.target.value === helpKey) { + openInNewTab(docsUrl('/docs/developers/creating-themes/')) + return + } + dispatch(changeTheme(event.target.value)) + }} + /> + ) +} diff --git a/ui/src/player/PlayerEdit.jsx b/ui/src/player/PlayerEdit.jsx new file mode 100644 index 0000000..1826500 --- /dev/null +++ b/ui/src/player/PlayerEdit.jsx @@ -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/PlayerList.jsx b/ui/src/player/PlayerList.jsx new file mode 100644 index 0000000..a2b009b --- /dev/null +++ b/ui/src/player/PlayerList.jsx @@ -0,0 +1,53 @@ +import React from 'react' +import { + Datagrid, + TextField, + DateField, + FunctionField, + ReferenceField, + Filter, + SearchInput, +} from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { SimpleList, List } from '../common' + +const PlayerFilter = (props) => ( + <Filter {...props} variant={'outlined'}> + <SearchInput id="search" source="name" alwaysOn /> + </Filter> +) + +const PlayerList = ({ permissions, ...props }) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + return ( + <List + {...props} + sort={{ field: 'lastSeen', order: 'DESC' }} + exporter={false} + filters={<PlayerFilter />} + > + {isXsmall ? ( + <SimpleList + primaryText={(r) => r.name} + secondaryText={(r) => r.userName} + tertiaryText={(r) => (r.maxBitRate ? r.maxBitRate : '-')} + /> + ) : ( + <Datagrid rowClick="edit"> + <TextField source="name" /> + {permissions === 'admin' && <TextField source="userName" />} + <ReferenceField source="transcodingId" reference="transcoding"> + <TextField source="name" /> + </ReferenceField> + <FunctionField + source="maxBitRate" + render={(r) => (r.maxBitRate ? r.maxBitRate : '-')} + /> + <DateField source="lastSeen" showTime sortByOrder={'DESC'} /> + </Datagrid> + )} + </List> + ) +} + +export default PlayerList diff --git a/ui/src/player/index.js b/ui/src/player/index.js new file mode 100644 index 0000000..aaa3d58 --- /dev/null +++ b/ui/src/player/index.js @@ -0,0 +1,9 @@ +import { BsFillMusicPlayerFill } from 'react-icons/bs' +import PlayerList from './PlayerList' +import PlayerEdit from './PlayerEdit' + +export default { + list: PlayerList, + edit: PlayerEdit, + icon: BsFillMusicPlayerFill, +} diff --git a/ui/src/playlist/ChangePublicStatusButton.jsx b/ui/src/playlist/ChangePublicStatusButton.jsx new file mode 100644 index 0000000..4f537d6 --- /dev/null +++ b/ui/src/playlist/ChangePublicStatusButton.jsx @@ -0,0 +1,17 @@ +import * as React from 'react' +import { LockOpen, Lock } from '@material-ui/icons' +import { BulkUpdateButton, useTranslate } from 'react-admin' + +const ChangePublicStatusButton = (props) => { + const translate = useTranslate() + const playlists = { public: props?.public } + const label = props?.public + ? translate('resources.playlist.actions.makePublic') + : translate('resources.playlist.actions.makePrivate') + const icon = props?.public ? <LockOpen /> : <Lock /> + return ( + <BulkUpdateButton {...props} data={playlists} label={label} icon={icon} /> + ) +} + +export default ChangePublicStatusButton diff --git a/ui/src/playlist/PlaylistActions.jsx b/ui/src/playlist/PlaylistActions.jsx new file mode 100644 index 0000000..1e7bef9 --- /dev/null +++ b/ui/src/playlist/PlaylistActions.jsx @@ -0,0 +1,182 @@ +import React from 'react' +import { useDispatch } from 'react-redux' +import { + Button, + sanitizeListRestProps, + TopToolbar, + useTranslate, + useDataProvider, + useNotify, +} from 'react-admin' +import { useMediaQuery, makeStyles } from '@material-ui/core' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' +import ShuffleIcon from '@material-ui/icons/Shuffle' +import CloudDownloadOutlinedIcon from '@material-ui/icons/CloudDownloadOutlined' +import { RiPlayListAddFill, RiPlayList2Fill } from 'react-icons/ri' +import QueueMusicIcon from '@material-ui/icons/QueueMusic' +import ShareIcon from '@material-ui/icons/Share' +import { httpClient } from '../dataProvider' +import { + playNext, + addTracks, + playTracks, + shuffleTracks, + openDownloadMenu, + DOWNLOAD_MENU_PLAY, + openShareMenu, +} from '../actions' +import { M3U_MIME_TYPE, REST_URL } from '../consts' +import PropTypes from 'prop-types' +import { formatBytes } from '../utils' +import config from '../config' +import { ToggleFieldsMenu } from '../common' + +const useStyles = makeStyles({ + toolbar: { display: 'flex', justifyContent: 'space-between', width: '100%' }, +}) + +const PlaylistActions = ({ className, ids, data, record, ...rest }) => { + const dispatch = useDispatch() + const translate = useTranslate() + const classes = useStyles() + const dataProvider = useDataProvider() + const notify = useNotify() + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + + const getAllSongsAndDispatch = React.useCallback( + (action) => { + if (ids?.length === record.songCount) { + return dispatch(action(data, ids)) + } + + dataProvider + .getList('playlistTrack', { + pagination: { page: 1, perPage: 0 }, + sort: { field: 'id', order: 'ASC' }, + filter: { playlist_id: record.id }, + }) + .then((res) => { + const data = res.data.reduce( + (acc, curr) => ({ ...acc, [curr.id]: curr }), + {}, + ) + dispatch(action(data)) + }) + .catch(() => { + notify('ra.page.error', 'warning') + }) + }, + [dataProvider, dispatch, record, data, ids, notify], + ) + + const handlePlay = React.useCallback(() => { + getAllSongsAndDispatch(playTracks) + }, [getAllSongsAndDispatch]) + + const handlePlayNext = React.useCallback(() => { + getAllSongsAndDispatch(playNext) + }, [getAllSongsAndDispatch]) + + const handlePlayLater = React.useCallback(() => { + getAllSongsAndDispatch(addTracks) + }, [getAllSongsAndDispatch]) + + const handleShuffle = React.useCallback(() => { + getAllSongsAndDispatch(shuffleTracks) + }, [getAllSongsAndDispatch]) + + const handleShare = React.useCallback(() => { + dispatch(openShareMenu([record.id], 'playlist', record.name)) + }, [dispatch, record]) + + const handleDownload = React.useCallback(() => { + dispatch(openDownloadMenu(record, DOWNLOAD_MENU_PLAY)) + }, [dispatch, record]) + + const handleExport = React.useCallback( + () => + httpClient(`${REST_URL}/playlist/${record.id}/tracks`, { + headers: new Headers({ Accept: M3U_MIME_TYPE }), + }).then((res) => { + const blob = new Blob([res.body], { type: M3U_MIME_TYPE }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${record.name}.m3u` + document.body.appendChild(link) + link.click() + link.parentNode.removeChild(link) + }), + [record], + ) + + return ( + <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + <div className={classes.toolbar}> + <div> + <Button + onClick={handlePlay} + label={translate('resources.album.actions.playAll')} + > + <PlayArrowIcon /> + </Button> + <Button + onClick={handleShuffle} + label={translate('resources.album.actions.shuffle')} + > + <ShuffleIcon /> + </Button> + <Button + onClick={handlePlayNext} + label={translate('resources.album.actions.playNext')} + > + <RiPlayList2Fill /> + </Button> + <Button + onClick={handlePlayLater} + label={translate('resources.album.actions.addToQueue')} + > + <RiPlayListAddFill /> + </Button> + {config.enableSharing && ( + <Button onClick={handleShare} label={translate('ra.action.share')}> + <ShareIcon /> + </Button> + )} + {config.enableDownloads && ( + <Button + onClick={handleDownload} + label={ + translate('ra.action.download') + + (isDesktop ? ` (${formatBytes(record.size)})` : '') + } + > + <CloudDownloadOutlinedIcon /> + </Button> + )} + <Button + onClick={handleExport} + label={translate('resources.playlist.actions.export')} + > + <QueueMusicIcon /> + </Button> + </div> + <div>{isNotSmall && <ToggleFieldsMenu resource="playlistTrack" />}</div> + </div> + </TopToolbar> + ) +} + +PlaylistActions.propTypes = { + record: PropTypes.object.isRequired, + selectedIds: PropTypes.arrayOf(PropTypes.number), +} + +PlaylistActions.defaultProps = { + record: {}, + selectedIds: [], + onUnselectItems: () => null, +} + +export default PlaylistActions diff --git a/ui/src/playlist/PlaylistCreate.jsx b/ui/src/playlist/PlaylistCreate.jsx new file mode 100644 index 0000000..ef5e1ac --- /dev/null +++ b/ui/src/playlist/PlaylistCreate.jsx @@ -0,0 +1,43 @@ +import React from 'react' +import { + Create, + SimpleForm, + TextInput, + BooleanInput, + required, + useTranslate, + useRefresh, + useNotify, + useRedirect, +} from 'react-admin' +import { Title } from '../common' + +const PlaylistCreate = (props) => { + const { basePath } = props + const refresh = useRefresh() + const notify = useNotify() + const redirect = useRedirect() + const translate = useTranslate() + const resourceName = translate('resources.playlist.name', { smart_count: 1 }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + + const onSuccess = () => { + notify('ra.notification.created', 'info', { smart_count: 1 }) + redirect('list', basePath) + refresh() + } + + return ( + <Create title={<Title subTitle={title} />} {...props} onSuccess={onSuccess}> + <SimpleForm redirect="list" variant={'outlined'}> + <TextInput source="name" validate={required()} /> + <TextInput multiline source="comment" /> + <BooleanInput source="public" initialValue={true} /> + </SimpleForm> + </Create> + ) +} + +export default PlaylistCreate diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx new file mode 100644 index 0000000..acccb15 --- /dev/null +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -0,0 +1,183 @@ +import { + Card, + CardContent, + CardMedia, + Typography, + useMediaQuery, +} from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import { useTranslate } from 'react-admin' +import { useCallback, useState, useEffect } from 'react' +import Lightbox from 'react-image-lightbox' +import 'react-image-lightbox/style.css' +import { CollapsibleComment, DurationField, SizeField } from '../common' +import subsonic from '../subsonic' + +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', + }, + cover: { + objectFit: 'contain', + cursor: 'pointer', + display: 'block', + width: '100%', + height: '100%', + backgroundColor: 'transparent', + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, + }, + title: { + overflow: 'hidden', + textOverflow: 'ellipsis', + wordBreak: 'break-word', + }, + 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 [imageLoading, setImageLoading] = useState(false) + const [imageError, setImageError] = useState(false) + + const imageUrl = subsonic.getCoverArtUrl(record, 300, true) + const fullImageUrl = subsonic.getCoverArtUrl(record) + + // Reset image state when playlist changes + useEffect(() => { + setImageLoading(true) + setImageError(false) + }, [record.id]) + + 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 ( + <Card className={classes.root}> + <div className={classes.cardContents}> + <div className={classes.coverParent}> + <CardMedia + key={record.id} // Force re-render when playlist changes + component={'img'} + src={imageUrl} + width="400" + height="400" + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onClick={handleOpenLightbox} + onLoad={handleImageLoad} + onError={handleImageError} + title={record.name} + style={{ + cursor: imageError ? 'default' : 'pointer', + }} + /> + </div> + <div className={classes.details}> + <CardContent className={classes.content}> + <Typography + variant={isDesktop ? 'h5' : 'h6'} + className={classes.title} + > + {record.name || translate('ra.page.loading')} + </Typography> + <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 && !imageError && ( + <Lightbox + imagePadding={50} + animationDuration={200} + imageTitle={record.name} + mainSrc={fullImageUrl} + onCloseRequest={handleCloseLightbox} + /> + )} + </Card> + ) +} + +export default PlaylistDetails diff --git a/ui/src/playlist/PlaylistEdit.jsx b/ui/src/playlist/PlaylistEdit.jsx new file mode 100644 index 0000000..f8cee9b --- /dev/null +++ b/ui/src/playlist/PlaylistEdit.jsx @@ -0,0 +1,67 @@ +import { + Edit, + FormDataConsumer, + SimpleForm, + TextInput, + TextField, + BooleanInput, + required, + useTranslate, + usePermissions, + ReferenceInput, + SelectInput, +} from 'react-admin' +import { isWritable, Title } from '../common' + +const SyncFragment = ({ formData, variant, ...rest }) => { + return ( + <> + {formData.path && <BooleanInput source="sync" {...rest} />} + {formData.path && <TextField source="path" {...rest} />} + </> + ) +} + +const PlaylistTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.playlist.name', { smart_count: 1 }) + return <Title subTitle={`${resourceName} "${record ? record.name : ''}"`} /> +} + +const PlaylistEditForm = (props) => { + const { record } = props + const { permissions } = usePermissions() + return ( + <SimpleForm redirect="list" variant={'outlined'} {...props}> + <TextInput source="name" validate={required()} /> + <TextInput multiline source="comment" /> + {permissions === 'admin' ? ( + <ReferenceInput + source="ownerId" + reference="user" + perPage={0} + sort={{ field: 'name', order: 'ASC' }} + > + <SelectInput + label={'resources.playlist.fields.ownerName'} + optionText="userName" + /> + </ReferenceInput> + ) : ( + <TextField source="ownerName" /> + )} + <BooleanInput source="public" disabled={!isWritable(record.ownerId)} /> + <FormDataConsumer> + {(formDataProps) => <SyncFragment {...formDataProps} />} + </FormDataConsumer> + </SimpleForm> + ) +} + +const PlaylistEdit = (props) => ( + <Edit title={<PlaylistTitle />} actions={false} {...props}> + <PlaylistEditForm {...props} /> + </Edit> +) + +export default PlaylistEdit diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx new file mode 100644 index 0000000..c185667 --- /dev/null +++ b/ui/src/playlist/PlaylistList.jsx @@ -0,0 +1,188 @@ +import React, { useMemo } from 'react' +import { + Datagrid, + DateField, + EditButton, + Filter, + NumberField, + ReferenceInput, + SearchInput, + SelectInput, + TextField, + useUpdate, + useNotify, + useRecordContext, + BulkDeleteButton, + 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 { + DurationField, + List, + Writable, + isWritable, + useSelectedFields, + useResourceRefresh, +} from '../common' +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 ( + <Filter {...props} variant={'outlined'}> + <SearchInput source="q" alwaysOn /> + {permissions === 'admin' && ( + <ReferenceInput + source="owner_id" + label={'resources.playlist.fields.ownerName'} + reference="user" + perPage={0} + sort={{ field: 'name', order: 'ASC' }} + alwaysOn + > + <SelectInput optionText="name" /> + </ReferenceInput> + )} + </Filter> + ) +} + +const TogglePublicInput = ({ resource, source }) => { + const record = useRecordContext() + const notify = useNotify() + const [togglePublic] = useUpdate( + resource, + record.id, + { + ...record, + public: !record.public, + }, + { + undoable: false, + onFailure: (error) => { + notify('ra.page.error', 'warning') + }, + }, + ) + + const handleClick = (e) => { + togglePublic() + e.stopPropagation() + } + + return ( + <Switch + checked={record[source]} + onClick={handleClick} + disabled={!isWritable(record.ownerId)} + /> + ) +} + +const ToggleAutoImport = ({ resource, source }) => { + const record = useRecordContext() + const notify = useNotify() + const [ToggleAutoImport] = useUpdate( + resource, + record.id, + { + ...record, + sync: !record.sync, + }, + { + undoable: false, + onFailure: (error) => { + notify('ra.page.error', 'warning') + }, + }, + ) + const handleClick = (e) => { + ToggleAutoImport() + e.stopPropagation() + } + + return record.path ? ( + <Switch + checked={record[source]} + onClick={handleClick} + disabled={!isWritable(record.ownerId)} + /> + ) : 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} /> + </> + ) +} + +const PlaylistList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + useResourceRefresh('playlist') + + const toggleableFields = useMemo( + () => ({ + ownerName: isDesktop && <TextField source="ownerName" />, + songCount: !isXsmall && <NumberField source="songCount" />, + duration: <DurationField source="duration" />, + updatedAt: isDesktop && ( + <DateField source="updatedAt" sortByOrder={'DESC'} /> + ), + public: !isXsmall && ( + <TogglePublicInput source="public" sortByOrder={'DESC'} /> + ), + comment: <TextField source="comment" />, + sync: <ToggleAutoImport source="sync" sortByOrder={'DESC'} />, + }), + [isDesktop, isXsmall], + ) + + const columns = useSelectedFields({ + resource: 'playlist', + columns: toggleableFields, + defaultOff: ['comment'], + }) + + return ( + <List + {...props} + exporter={false} + filters={<PlaylistFilter />} + actions={<PlaylistListActions />} + bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} + > + <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> + <TextField source="name" /> + {columns} + <Writable> + <EditButton /> + </Writable> + </Datagrid> + </List> + ) +} + +export default PlaylistList diff --git a/ui/src/playlist/PlaylistListActions.jsx b/ui/src/playlist/PlaylistListActions.jsx new file mode 100644 index 0000000..3cde0a2 --- /dev/null +++ b/ui/src/playlist/PlaylistListActions.jsx @@ -0,0 +1,26 @@ +import React, { cloneElement } from 'react' +import { + sanitizeListRestProps, + TopToolbar, + CreateButton, + useTranslate, +} from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { ToggleFieldsMenu } from '../common' + +const PlaylistListActions = ({ className, ...rest }) => { + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const translate = useTranslate() + + return ( + <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + {cloneElement(rest.filters, { context: 'button' })} + <CreateButton basePath="/playlist"> + {translate('ra.action.create')} + </CreateButton> + {isNotSmall && <ToggleFieldsMenu resource="playlist" />} + </TopToolbar> + ) +} + +export default PlaylistListActions diff --git a/ui/src/playlist/PlaylistShow.jsx b/ui/src/playlist/PlaylistShow.jsx new file mode 100644 index 0000000..f0cb472 --- /dev/null +++ b/ui/src/playlist/PlaylistShow.jsx @@ -0,0 +1,76 @@ +import React from 'react' +import { + ReferenceManyField, + ShowContextProvider, + useShowContext, + useShowController, + Pagination, + Title as RaTitle, +} from 'react-admin' +import { makeStyles } from '@material-ui/core/styles' +import PlaylistDetails from './PlaylistDetails' +import PlaylistSongs from './PlaylistSongs' +import PlaylistActions from './PlaylistActions' +import { Title, canChangeTracks, useResourceRefresh } from '../common' + +const useStyles = makeStyles( + (theme) => ({ + playlistActions: { + width: '100%', + }, + }), + { + name: 'NDPlaylistShow', + }, +) + +const PlaylistShowLayout = (props) => { + const { loading, ...context } = useShowContext(props) + const { record } = context + const classes = useStyles() + useResourceRefresh('song') + + return ( + <> + {record && <RaTitle title={<Title subTitle={record.name} />} />} + {record && <PlaylistDetails {...context} />} + {record && ( + <ReferenceManyField + {...context} + addLabel={false} + reference="playlistTrack" + target="playlist_id" + sort={{ field: 'id', order: 'ASC' }} + perPage={100} + filter={{ playlist_id: props.id }} + > + <PlaylistSongs + {...props} + readOnly={!canChangeTracks(record)} + title={<Title subTitle={record.name} />} + actions={ + <PlaylistActions + className={classes.playlistActions} + record={record} + /> + } + resource={'playlistTrack'} + exporter={false} + pagination={<Pagination rowsPerPageOptions={[100, 250, 500]} />} + /> + </ReferenceManyField> + )} + </> + ) +} + +const PlaylistShow = (props) => { + const controllerProps = useShowController(props) + return ( + <ShowContextProvider value={controllerProps}> + <PlaylistShowLayout {...props} {...controllerProps} /> + </ShowContextProvider> + ) +} + +export default PlaylistShow diff --git a/ui/src/playlist/PlaylistSongBulkActions.jsx b/ui/src/playlist/PlaylistSongBulkActions.jsx new file mode 100644 index 0000000..ac19f96 --- /dev/null +++ b/ui/src/playlist/PlaylistSongBulkActions.jsx @@ -0,0 +1,42 @@ +import React, { Fragment, useEffect } from 'react' +import { + BulkDeleteButton, + 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 +const PlaylistSongBulkActions = ({ + playlistId, + resource, + onUnselectItems, + ...rest +}) => { + const unselectAll = useUnselectAll() + useEffect(() => { + unselectAll('playlistTrack') + }, [unselectAll]) + + const mappedResource = `playlist/${playlistId}/tracks` + return ( + <ResourceContextProvider value={mappedResource}> + <Fragment> + <BulkDeleteButton + {...rest} + label={'ra.action.remove'} + icon={<MdOutlinePlaylistRemove />} + resource={mappedResource} + onClick={onUnselectItems} + /> + </Fragment> + </ResourceContextProvider> + ) +} + +PlaylistSongBulkActions.propTypes = { + playlistId: PropTypes.string.isRequired, +} + +export default PlaylistSongBulkActions diff --git a/ui/src/playlist/PlaylistSongs.jsx b/ui/src/playlist/PlaylistSongs.jsx new file mode 100644 index 0000000..bbe38b4 --- /dev/null +++ b/ui/src/playlist/PlaylistSongs.jsx @@ -0,0 +1,264 @@ +import React, { useCallback, useEffect, useMemo } from 'react' +import { + BulkActionsToolbar, + ListToolbar, + TextField, + NumberField, + useDataProvider, + useNotify, + useVersion, + useListContext, + FunctionField, +} from 'react-admin' +import clsx from 'clsx' +import { useDispatch } from 'react-redux' +import { Card, useMediaQuery } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' +import ReactDragListView from 'react-drag-listview' +import { + DurationField, + SongInfo, + SongContextMenu, + SongDatagrid, + SongTitleField, + QualityInfo, + useSelectedFields, + 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) => ({ + root: {}, + main: { + display: 'flex', + }, + content: { + marginTop: 0, + transition: theme.transitions.create('margin-top'), + position: 'relative', + flex: '1 1 auto', + [theme.breakpoints.down('xs')]: { + boxShadow: 'none', + }, + }, + bulkActionsDisplayed: { + marginTop: -theme.spacing(8), + transition: theme.transitions.create('margin-top'), + }, + actions: { + zIndex: 2, + display: 'flex', + justifyContent: 'flex-end', + flexWrap: 'wrap', + }, + noResults: { padding: 20 }, + toolbar: { + justifyContent: 'flex-start', + }, + row: { + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + '& $ratingField': { + visibility: 'visible', + }, + }, + }, + contextMenu: { + visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'), + }, + ratingField: { + visibility: 'hidden', + }, + }), + { name: 'RaList' }, +) + +const ReorderableList = ({ readOnly, children, ...rest }) => { + if (readOnly) { + return children + } + return <ReactDragListView {...rest}>{children}</ReactDragListView> +} + +const PlaylistSongs = ({ playlistId, readOnly, actions, ...props }) => { + const listContext = useListContext() + const { data, ids, selectedIds, onUnselectItems, refetch, setPage } = + 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() + useResourceRefresh('song', 'playlist') + + useEffect(() => { + setPage(1) + window.scrollTo({ top: 0, behavior: 'smooth' }) + }, [playlistId, setPage]) + + const onAddToPlaylist = useCallback( + (pls) => { + if (pls.id === playlistId) { + refetch() + } + }, + [playlistId, refetch], + ) + + const reorder = useCallback( + (playlistId, id, newPos) => { + dataProvider + .update('playlistTrack', { + id, + data: { insert_before: newPos }, + filter: { playlist_id: playlistId }, + }) + .then(() => { + refetch() + }) + .catch(() => { + notify('ra.page.error', 'warning') + }) + }, + [dataProvider, notify, refetch], + ) + + const handleDragEnd = useCallback( + (from, to) => { + const toId = ids[to] + const fromId = ids[from] + reorder(playlistId, fromId, toId) + }, + [playlistId, reorder, ids], + ) + + const toggleableFields = useMemo(() => { + return { + trackNumber: isDesktop && <TextField source="id" label={'#'} />, + title: <SongTitleField source="title" showTrackNumbers={false} />, + album: isDesktop && <AlbumLinkField source="album" />, + artist: isDesktop && <ArtistLinkField source="artist" />, + albumArtist: isDesktop && <ArtistLinkField source="albumArtist" />, + duration: ( + <DurationField source="duration" className={classes.draggable} /> + ), + year: isDesktop && ( + <FunctionField + source="year" + render={(r) => r.year || ''} + sortByOrder={'DESC'} + /> + ), + playCount: isDesktop && ( + <NumberField source="playCount" sortByOrder={'DESC'} /> + ), + playDate: isDesktop && ( + <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]) + + const columns = useSelectedFields({ + resource: 'playlistTrack', + columns: toggleableFields, + defaultOff: [ + 'channels', + 'bpm', + 'year', + 'playCount', + 'playDate', + 'albumArtist', + 'genre', + 'rating', + ], + }) + + return ( + <> + <ListToolbar + classes={{ toolbar: classes.toolbar }} + filters={props.filters} + actions={actions} + /> + <div className={classes.main}> + <Card + className={clsx(classes.content, { + [classes.bulkActionsDisplayed]: selectedIds.length > 0, + })} + key={version} + > + <BulkActionsToolbar> + <PlaylistSongBulkActions + playlistId={playlistId} + onUnselectItems={onUnselectItems} + readOnly={readOnly} + /> + </BulkActionsToolbar> + <ReorderableList + readOnly={readOnly} + onDragEnd={handleDragEnd} + nodeSelector={'tr'} + > + <SongDatagrid + rowClick={(id) => dispatch(playTracks(data, ids, id))} + {...listContext} + hasBulkActions={!readOnly} + contextAlwaysVisible={!isDesktop} + classes={{ row: classes.row }} + > + {columns} + <SongContextMenu + onAddToPlaylist={onAddToPlaylist} + showLove={true} + className={classes.contextMenu} + /> + </SongDatagrid> + </ReorderableList> + </Card> + </div> + <ExpandInfoDialog content={<SongInfo />} /> + {React.cloneElement(props.pagination, listContext)} + </> + ) +} + +const SanitizedPlaylistSongs = (props) => { + const { loaded, ...rest } = props + return ( + <> + {loaded && ( + <PlaylistSongs + playlistId={props.id} + actions={props.actions} + pagination={props.pagination} + {...rest} + /> + )} + </> + ) +} + +export default SanitizedPlaylistSongs diff --git a/ui/src/playlist/index.jsx b/ui/src/playlist/index.jsx new file mode 100644 index 0000000..3a7111d --- /dev/null +++ b/ui/src/playlist/index.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import QueueMusicOutlinedIcon from '@material-ui/icons/QueueMusicOutlined' +import QueueMusicIcon from '@material-ui/icons/QueueMusic' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' +import PlaylistList from './PlaylistList' +import PlaylistEdit from './PlaylistEdit' +import PlaylistCreate from './PlaylistCreate' +import PlaylistShow from './PlaylistShow' + +export default { + list: PlaylistList, + create: PlaylistCreate, + edit: PlaylistEdit, + show: PlaylistShow, + icon: ( + <DynamicMenuIcon + path={'playlist'} + icon={QueueMusicOutlinedIcon} + activeIcon={QueueMusicIcon} + /> + ), +} diff --git a/ui/src/playlist/styles.js b/ui/src/playlist/styles.js new file mode 100644 index 0000000..0881fda --- /dev/null +++ b/ui/src/playlist/styles.js @@ -0,0 +1,47 @@ +import { makeStyles } from '@material-ui/core/styles' + +export const useStyles = makeStyles((theme) => ({ + container: { + [theme.breakpoints.down('xs')]: { + padding: '0.7em', + minWidth: '24em', + }, + [theme.breakpoints.up('sm')]: { + padding: '1em', + minWidth: '32em', + }, + }, + albumCover: { + display: 'inline-block', + [theme.breakpoints.down('xs')]: { + height: '8em', + width: '8em', + }, + [theme.breakpoints.up('sm')]: { + height: '10em', + width: '10em', + }, + [theme.breakpoints.up('lg')]: { + height: '15em', + width: '15em', + }, + }, + albumDetails: { + display: 'inline-block', + verticalAlign: 'top', + [theme.breakpoints.down('xs')]: { + width: '14em', + }, + [theme.breakpoints.up('sm')]: { + width: '26em', + }, + [theme.breakpoints.up('lg')]: { + width: '38em', + }, + }, + albumTitle: { + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, +})) diff --git a/ui/src/radio/RadioCreate.jsx b/ui/src/radio/RadioCreate.jsx new file mode 100644 index 0000000..04398d2 --- /dev/null +++ b/ui/src/radio/RadioCreate.jsx @@ -0,0 +1,44 @@ +import { + Create, + required, + SimpleForm, + TextInput, + useTranslate, +} from 'react-admin' +import { Title } from '../common' +import { urlValidate } from '../utils/validations' + +const RadioTitle = () => { + const translate = useTranslate() + const resourceName = translate('resources.radio.name', { + smart_count: 1, + }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + return <Title subTitle={title} /> +} + +const RadioCreate = (props) => { + return ( + <Create title={<RadioTitle />} {...props}> + <SimpleForm redirect="list" variant={'outlined'}> + <TextInput source="name" validate={[required()]} /> + <TextInput + type="url" + source="streamUrl" + fullWidth + validate={[required(), urlValidate]} + /> + <TextInput + type="url" + source="homePageUrl" + fullWidth + validate={[urlValidate]} + /> + </SimpleForm> + </Create> + ) +} + +export default RadioCreate diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx new file mode 100644 index 0000000..f00f889 --- /dev/null +++ b/ui/src/radio/RadioEdit.jsx @@ -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/RadioList.jsx b/ui/src/radio/RadioList.jsx new file mode 100644 index 0000000..3d1adac --- /dev/null +++ b/ui/src/radio/RadioList.jsx @@ -0,0 +1,144 @@ +import { makeStyles, useMediaQuery } from '@material-ui/core' +import React, { cloneElement } from 'react' +import { + CreateButton, + Datagrid, + DateField, + EditButton, + Filter, + sanitizeListRestProps, + SearchInput, + SimpleList, + TextField, + TopToolbar, + UrlField, + useTranslate, +} from 'react-admin' +import { List } from '../common' +import { ToggleFieldsMenu, useSelectedFields } from '../common' +import { StreamField } from './StreamField' +import { setTrack } from '../actions' +import { songFromRadio } from './helper' +import { useDispatch } from 'react-redux' + +const useStyles = makeStyles({ + row: { + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + }, + }, + contextMenu: { + visibility: 'hidden', + }, +}) + +const RadioFilter = (props) => ( + <Filter {...props} variant={'outlined'}> + <SearchInput id="search" source="name" alwaysOn /> + </Filter> +) + +const RadioListActions = ({ + className, + filters, + resource, + showFilter, + displayedFilters, + filterValues, + isAdmin, + ...rest +}) => { + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + const translate = useTranslate() + + return ( + <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + {isAdmin && ( + <CreateButton basePath="/radio"> + {translate('ra.action.create')} + </CreateButton> + )} + {filters && + cloneElement(filters, { + resource, + showFilter, + displayedFilters, + filterValues, + context: 'button', + })} + {isNotSmall && <ToggleFieldsMenu resource="radio" />} + </TopToolbar> + ) +} + +const RadioList = ({ permissions, ...props }) => { + const classes = useStyles() + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const dispatch = useDispatch() + const isAdmin = permissions === 'admin' + + const toggleableFields = { + name: <TextField source="name" />, + homePageUrl: ( + <UrlField + source="homePageUrl" + onClick={(e) => e.stopPropagation()} + target="_blank" + rel="noopener noreferrer" + /> + ), + streamUrl: <TextField source="streamUrl" />, + updatedAt: <DateField source="updatedAt" showTime />, + createdAt: <DateField source="createdAt" showTime />, + } + + const columns = useSelectedFields({ + resource: 'radio', + columns: toggleableFields, + defaultOff: ['createdAt'], + }) + + const handleRowClick = async (id, basePath, record) => { + dispatch(setTrack(await songFromRadio(record))) + } + + return ( + <List + {...props} + exporter={false} + sort={{ field: 'name', order: 'ASC' }} + bulkActionButtons={isAdmin ? undefined : false} + hasCreate={isAdmin} + actions={<RadioListActions isAdmin={isAdmin} />} + filters={<RadioFilter />} + perPage={isXsmall ? 25 : 10} + > + {isXsmall ? ( + <SimpleList + leftIcon={(r) => ( + <StreamField + record={r} + source={'streamUrl'} + hideUrl + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} + /> + )} + primaryText={(r) => r.name} + secondaryText={(r) => r.homePageUrl} + /> + ) : ( + <Datagrid rowClick={handleRowClick} classes={{ row: classes.row }}> + {columns} + {isAdmin && <EditButton />} + </Datagrid> + )} + </List> + ) +} + +export default RadioList diff --git a/ui/src/radio/StreamField.jsx b/ui/src/radio/StreamField.jsx new file mode 100644 index 0000000..2327f3c --- /dev/null +++ b/ui/src/radio/StreamField.jsx @@ -0,0 +1,47 @@ +import { Button, makeStyles } from '@material-ui/core' +import PropTypes from 'prop-types' +import React, { useCallback } from 'react' +import { useRecordContext } from 'react-admin' +import { useDispatch } from 'react-redux' +import { setTrack } from '../actions' +import { songFromRadio } from './helper' +import PlayArrowIcon from '@material-ui/icons/PlayArrow' + +const useStyles = makeStyles((theme) => ({ + button: { + padding: '5px 0px', + textTransform: 'none', + marginRight: theme.spacing(1.5), + }, +})) + +export const StreamField = (props) => { + const record = useRecordContext(props) + const dispatch = useDispatch() + const classes = useStyles() + + const playTrack = useCallback( + async (evt) => { + evt.stopPropagation() + evt.preventDefault() + dispatch(setTrack(await songFromRadio(record))) + }, + [dispatch, record], + ) + + return ( + <Button className={classes.button} onClick={playTrack}> + <PlayArrowIcon /> + </Button> + ) +} + +StreamField.propTypes = { + label: PropTypes.string, + record: PropTypes.object, + source: PropTypes.string.isRequired, +} + +StreamField.defaultProps = { + addLabel: true, +} diff --git a/ui/src/radio/helper.jsx b/ui/src/radio/helper.jsx new file mode 100644 index 0000000..57de244 --- /dev/null +++ b/ui/src/radio/helper.jsx @@ -0,0 +1,37 @@ +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 { + // ignore + } + + 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.jsx new file mode 100644 index 0000000..8695630 --- /dev/null +++ b/ui/src/radio/index.jsx @@ -0,0 +1,26 @@ +import RadioCreate from './RadioCreate' +import RadioEdit from './RadioEdit' +import RadioList from './RadioList' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' +import RadioIcon from '@material-ui/icons/Radio' +import RadioOutlinedIcon from '@material-ui/icons/RadioOutlined' +import React from 'react' + +const all = { + list: RadioList, + icon: ( + <DynamicMenuIcon + path={'radio'} + icon={RadioOutlinedIcon} + activeIcon={RadioIcon} + /> + ), +} + +const admin = { + ...all, + create: RadioCreate, + edit: RadioEdit, +} + +export default { all, admin } diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js new file mode 100644 index 0000000..8238e39 --- /dev/null +++ b/ui/src/reducers/activityReducer.js @@ -0,0 +1,54 @@ +import { + EVENT_REFRESH_RESOURCE, + EVENT_SCAN_STATUS, + EVENT_SERVER_START, + EVENT_NOW_PLAYING_COUNT, + EVENT_STREAM_RECONNECTED, +} from '../actions' +import config from '../config' + +const initialState = { + scanStatus: { + scanning: false, + folderCount: 0, + count: 0, + error: '', + elapsedTime: 0, + }, + serverStart: { version: config.version }, + nowPlayingCount: 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_SERVER_START: + return { + ...previousState, + serverStart: { + startTime: data.startTime && Date.parse(data.startTime), + version: data.version, + }, + } + case EVENT_REFRESH_RESOURCE: + return { + ...previousState, + refresh: { + lastReceived: Date.now(), + resources: data, + }, + } + case EVENT_NOW_PLAYING_COUNT: + 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 new file mode 100644 index 0000000..c9db38d --- /dev/null +++ b/ui/src/reducers/activityReducer.test.js @@ -0,0 +1,148 @@ +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, + 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_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 new file mode 100644 index 0000000..ef68334 --- /dev/null +++ b/ui/src/reducers/albumView.js @@ -0,0 +1,17 @@ +import { ALBUM_MODE_GRID, ALBUM_MODE_TABLE } from '../actions' + +export const albumViewReducer = ( + previousState = { + grid: true, + }, + payload, +) => { + const { type } = payload + switch (type) { + case ALBUM_MODE_GRID: + case ALBUM_MODE_TABLE: + return { ...previousState, grid: type === ALBUM_MODE_GRID } + default: + return previousState + } +} diff --git a/ui/src/reducers/dialogReducer.js b/ui/src/reducers/dialogReducer.js new file mode 100644 index 0000000..e1a77f1 --- /dev/null +++ b/ui/src/reducers/dialogReducer.js @@ -0,0 +1,188 @@ +import { + ADD_TO_PLAYLIST_CLOSE, + ADD_TO_PLAYLIST_OPEN, + DOWNLOAD_MENU_ALBUM, + DOWNLOAD_MENU_ARTIST, + DOWNLOAD_MENU_CLOSE, + DOWNLOAD_MENU_OPEN, + DOWNLOAD_MENU_PLAY, + DOWNLOAD_MENU_SONG, + DUPLICATE_SONG_WARNING_OPEN, + DUPLICATE_SONG_WARNING_CLOSE, + EXTENDED_INFO_OPEN, + EXTENDED_INFO_CLOSE, + LISTENBRAINZ_TOKEN_OPEN, + LISTENBRAINZ_TOKEN_CLOSE, + SAVE_QUEUE_OPEN, + SAVE_QUEUE_CLOSE, + SHARE_MENU_OPEN, + SHARE_MENU_CLOSE, +} from '../actions' + +export const shareDialogReducer = ( + previousState = { + open: false, + ids: [], + resource: '', + name: '', + }, + payload, +) => { + const { type, ids, resource, name, label } = payload + switch (type) { + case SHARE_MENU_OPEN: + return { + ...previousState, + open: true, + ids, + resource, + name, + label, + } + case SHARE_MENU_CLOSE: + return { + ...previousState, + open: false, + } + default: + return previousState + } +} + +export const addToPlaylistDialogReducer = ( + previousState = { + open: false, + duplicateSong: false, + }, + payload, +) => { + const { type } = payload + switch (type) { + case ADD_TO_PLAYLIST_OPEN: + return { + ...previousState, + open: true, + selectedIds: payload.selectedIds, + onSuccess: payload.onSuccess, + } + case ADD_TO_PLAYLIST_CLOSE: + return { ...previousState, open: false, onSuccess: undefined } + case DUPLICATE_SONG_WARNING_OPEN: + return { + ...previousState, + duplicateSong: true, + duplicateIds: payload.duplicateIds, + } + case DUPLICATE_SONG_WARNING_CLOSE: + return { ...previousState, duplicateSong: false } + default: + return previousState + } +} + +export const downloadMenuDialogReducer = ( + previousState = { + open: false, + }, + payload, +) => { + const { type } = payload + switch (type) { + case DOWNLOAD_MENU_OPEN: { + switch (payload.recordType) { + case DOWNLOAD_MENU_ALBUM: + case DOWNLOAD_MENU_ARTIST: + case DOWNLOAD_MENU_PLAY: + case DOWNLOAD_MENU_SONG: { + return { + ...previousState, + open: true, + record: payload.record, + recordType: payload.recordType, + } + } + default: { + return { + ...previousState, + open: true, + record: payload.record, + recordType: undefined, + } + } + } + } + case DOWNLOAD_MENU_CLOSE: { + return { + ...previousState, + open: false, + recordType: undefined, + } + } + default: + return previousState + } +} + +export const expandInfoDialogReducer = ( + previousState = { + open: false, + record: undefined, + }, + payload, +) => { + const { type } = payload + switch (type) { + case EXTENDED_INFO_OPEN: + return { + ...previousState, + open: true, + record: payload.record, + } + case EXTENDED_INFO_CLOSE: + return { + ...previousState, + open: false, + record: undefined, + } + default: + return previousState + } +} + +export const listenBrainzTokenDialogReducer = ( + previousState = { + open: false, + }, + payload, +) => { + const { type } = payload + switch (type) { + case LISTENBRAINZ_TOKEN_OPEN: + return { + ...previousState, + open: true, + } + case LISTENBRAINZ_TOKEN_CLOSE: + return { + ...previousState, + open: false, + } + default: + 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 new file mode 100644 index 0000000..3db0b1d --- /dev/null +++ b/ui/src/reducers/index.js @@ -0,0 +1,8 @@ +export * from './libraryReducer' +export * from './themeReducer' +export * from './dialogReducer' +export * from './playerReducer' +export * from './albumView' +export * from './activityReducer' +export * from './settingsReducer' +export * from './replayGainReducer' diff --git a/ui/src/reducers/libraryReducer.js b/ui/src/reducers/libraryReducer.js new file mode 100644 index 0000000..ef61326 --- /dev/null +++ b/ui/src/reducers/libraryReducer.js @@ -0,0 +1,52 @@ +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 new file mode 100644 index 0000000..b962c10 --- /dev/null +++ b/ui/src/reducers/libraryReducer.test.js @@ -0,0 +1,186 @@ +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 new file mode 100644 index 0000000..92fe85d --- /dev/null +++ b/ui/src/reducers/playerReducer.js @@ -0,0 +1,213 @@ +import { v4 as uuidv4 } from 'uuid' +import subsonic from '../subsonic' +import { + PLAYER_ADD_TRACKS, + PLAYER_CLEAR_QUEUE, + PLAYER_CURRENT, + PLAYER_PLAY_NEXT, + PLAYER_PLAY_TRACKS, + PLAYER_SET_TRACK, + PLAYER_SET_VOLUME, + PLAYER_SYNC_QUEUE, + PLAYER_SET_MODE, +} from '../actions' +import config from '../config' + +const initialState = { + queue: [], + current: {}, + clear: false, + volume: config.defaultUIVolume / 100, + savedPlayIndex: 0, +} + +const pad = (value) => { + const str = value.toString() + if (str.length === 1) { + return `0${str}` + } else { + return str + } +} + +const mapToAudioLists = (item) => { + // If item comes from a playlist, trackId is mediaFileId + const trackId = item.mediaFileId || item.id + + if (item.isRadio) { + return { + trackId, + uuid: uuidv4(), + name: item.name, + song: item, + musicSrc: item.streamUrl, + cover: item.cover, + isRadio: true, + } + } + + 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, + singer: item.artist, + duration: item.duration, + musicSrc: subsonic.streamUrl(trackId), + cover: subsonic.getCoverArtUrl( + { + id: trackId, + updatedAt: item.updatedAt, + album: item.album, + }, + 300, + ), + } +} + +const reduceClearQueue = () => ({ ...initialState, clear: true }) + +const reducePlayTracks = (state, { data, id }) => { + let playIndex = 0 + const queue = Object.keys(data).map((key, idx) => { + if (key === id) { + playIndex = idx + } + return mapToAudioLists(data[key]) + }) + return { + ...state, + queue, + playIndex, + clear: true, + } +} + +const reduceSetTrack = (state, { data }) => { + return { + ...state, + queue: [mapToAudioLists(data)], + playIndex: 0, + clear: true, + } +} + +const reduceAddTracks = (state, { data }) => { + const queue = state.queue + Object.keys(data).forEach((id) => { + queue.push(mapToAudioLists(data[id])) + }) + return { ...state, queue, clear: false } +} + +const reducePlayNext = (state, { data }) => { + const newQueue = [] + const current = state.current || {} + let foundPos = false + state.queue.forEach((item) => { + newQueue.push(item) + if (item.uuid === current.uuid) { + foundPos = true + Object.keys(data).forEach((id) => { + newQueue.push(mapToAudioLists(data[id])) + }) + } + }) + if (!foundPos) { + Object.keys(data).forEach((id) => { + newQueue.push(mapToAudioLists(data[id])) + }) + } + + return { + ...state, + queue: newQueue, + clear: true, + } +} + +const reduceSetVolume = (state, { data: { volume } }) => { + return { + ...state, + volume, + } +} + +const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { + return { + ...state, + queue: audioLists, + clear: false, + playIndex: undefined, + } +} + +const reduceCurrent = (state, { data }) => { + const current = data.ended ? {} : data + const savedPlayIndex = state.queue.findIndex( + (item) => item.uuid === current.uuid, + ) + return { + ...state, + current, + 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) { + case PLAYER_CLEAR_QUEUE: + return reduceClearQueue() + case PLAYER_PLAY_TRACKS: + return reducePlayTracks(previousState, payload) + case PLAYER_SET_TRACK: + return reduceSetTrack(previousState, payload) + case PLAYER_ADD_TRACKS: + return reduceAddTracks(previousState, payload) + case PLAYER_PLAY_NEXT: + return reducePlayNext(previousState, payload) + case PLAYER_SET_VOLUME: + return reduceSetVolume(previousState, payload) + case PLAYER_SYNC_QUEUE: + return reduceSyncQueue(previousState, payload) + case PLAYER_CURRENT: + return reduceCurrent(previousState, payload) + case PLAYER_SET_MODE: + return reduceMode(previousState, payload) + default: + return previousState + } +} diff --git a/ui/src/reducers/replayGainReducer.js b/ui/src/reducers/replayGainReducer.js new file mode 100644 index 0000000..41b087a --- /dev/null +++ b/ui/src/reducers/replayGainReducer.js @@ -0,0 +1,48 @@ +import { CHANGE_GAIN, CHANGE_PREAMP } from '../actions' + +const GAIN_KEY = 'gainMode' +const PREAMP_KEY = 'preAmp' + +const getPreAmp = () => { + const storage = localStorage.getItem(PREAMP_KEY) + + if (storage === null) { + return 0 + } else { + const asFloat = parseFloat(storage) + return isNaN(asFloat) ? 0 : asFloat + } +} + +const initialState = { + gainMode: localStorage.getItem(GAIN_KEY) || 'none', + preAmp: getPreAmp(), +} + +export const replayGainReducer = ( + previousState = initialState, + { type, payload }, +) => { + switch (type) { + case CHANGE_GAIN: { + localStorage.setItem(GAIN_KEY, payload) + return { + ...previousState, + gainMode: payload, + } + } + case CHANGE_PREAMP: { + const value = parseFloat(payload) + if (isNaN(value)) { + return previousState + } + localStorage.setItem(PREAMP_KEY, payload) + return { + ...previousState, + preAmp: value, + } + } + default: + return previousState + } +} diff --git a/ui/src/reducers/settingsReducer.js b/ui/src/reducers/settingsReducer.js new file mode 100644 index 0000000..0e598c2 --- /dev/null +++ b/ui/src/reducers/settingsReducer.js @@ -0,0 +1,40 @@ +import { + SET_NOTIFICATIONS_STATE, + SET_OMITTED_FIELDS, + SET_TOGGLEABLE_FIELDS, +} from '../actions' + +const initialState = { + notifications: false, + toggleableFields: {}, + omittedFields: {}, +} + +export const settingsReducer = (previousState = initialState, payload) => { + const { type, data } = payload + switch (type) { + case SET_NOTIFICATIONS_STATE: + return { + ...previousState, + notifications: data, + } + case SET_TOGGLEABLE_FIELDS: + return { + ...previousState, + toggleableFields: { + ...previousState.toggleableFields, + ...data, + }, + } + case SET_OMITTED_FIELDS: + return { + ...previousState, + omittedFields: { + ...previousState.omittedFields, + ...data, + }, + } + default: + return previousState + } +} diff --git a/ui/src/reducers/themeReducer.js b/ui/src/reducers/themeReducer.js new file mode 100644 index 0000000..2a5d5ba --- /dev/null +++ b/ui/src/reducers/themeReducer.js @@ -0,0 +1,21 @@ +import { CHANGE_THEME } from '../actions' +import config from '../config' +import themes from '../themes' + +const defaultTheme = () => { + return ( + Object.keys(themes).find( + (t) => themes[t].themeName === config.defaultTheme, + ) || 'DarkTheme' + ) +} + +export const themeReducer = ( + previousState = defaultTheme(), + { type, payload }, +) => { + if (type === CHANGE_THEME) { + return payload + } + return previousState +} diff --git a/ui/src/routes.jsx b/ui/src/routes.jsx new file mode 100644 index 0000000..0b36ea5 --- /dev/null +++ b/ui/src/routes.jsx @@ -0,0 +1,9 @@ +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'} />, +] + +export default routes diff --git a/ui/src/setupTests.js b/ui/src/setupTests.js new file mode 100644 index 0000000..ddb999f --- /dev/null +++ b/ui/src/setupTests.js @@ -0,0 +1,27 @@ +// jest-dom adds custom jest matchers for asserting on DOM nodes. +// 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' + +const localStorageMock = (function () { + let store = {} + + return { + getItem: function (key) { + return store[key] || null + }, + setItem: function (key, value) { + store[key] = value.toString() + }, + clear: function () { + store = {} + }, + } +})() + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock, +}) + +localStorage.setItem('username', 'admin') diff --git a/ui/src/share/ShareEdit.jsx b/ui/src/share/ShareEdit.jsx new file mode 100644 index 0000000..2cf7f2d --- /dev/null +++ b/ui/src/share/ShareEdit.jsx @@ -0,0 +1,36 @@ +import { + DateTimeInput, + BooleanInput, + Edit, + NumberField, + SimpleForm, + TextInput, +} from 'react-admin' +import { sharePlayerUrl } 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 = sharePlayerUrl(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/ShareList.jsx b/ui/src/share/ShareList.jsx new file mode 100644 index 0000000..48ab44e --- /dev/null +++ b/ui/src/share/ShareList.jsx @@ -0,0 +1,120 @@ +import { + Datagrid, + FunctionField, + BooleanField, + 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 config from '../config' + +export const FormatInfo = ({ record, size }) => { + const r = { suffix: record.format, bitRate: record.maxBitRate } + r.suffix = + r.suffix || (r.bitRate ? config.defaultDownsamplingFormat : 'Original') + return <QualityInfo record={r} size={size} /> +} + +const ShareList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) + const translate = useTranslate() + const notify = useNotify() + + const handleShare = (r) => (e) => { + const url = sharePlayerUrl(r?.id) + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard + .writeText(url) + .then(() => { + notify(translate('message.shareSuccess', { url }), { + type: 'info', + multiLine: true, + duration: 0, + }) + }) + .catch((err) => { + notify( + translate('message.shareFailure', { url }) + ': ' + err.message, + { + type: 'warning', + multiLine: true, + duration: 0, + }, + ) + }) + } else prompt(translate('message.shareCopyToClipboard'), url) + + e.preventDefault() + e.stopPropagation() + } + + return ( + <List + {...props} + sort={{ field: 'createdAt', order: 'DESC' }} + exporter={false} + > + {isXsmall ? ( + <SimpleList + leftIcon={(r) => ( + <IconButton onClick={handleShare(r)}> + <ShareIcon /> + </IconButton> + )} + primaryText={(r) => r.description || r.contents || r.id} + secondaryText={(r) => ( + <> + {translate('resources.share.fields.expiresAt')}:{' '} + <DateField record={r} source={'expiresAt'} /> + </> + )} + tertiaryText={(r) => + `${translate('resources.share.fields.visitCount')}: ${ + r.visitCount || '0' + }` + } + /> + ) : ( + <Datagrid rowClick="edit"> + <FunctionField + source={'id'} + render={(r) => ( + <Link + href={sharePlayerUrl(r.id)} + label="URL" + target="_blank" + rel="noopener noreferrer" + onClick={(e) => { + e.stopPropagation() + }} + > + {r.id} + </Link> + )} + /> + <TextField source="username" /> + <TextField source="description" /> + {isDesktop && <TextField source="contents" />} + {isDesktop && <FormatInfo source="format" />} + {config.enableDownloads && <BooleanField source="downloadable" />} + <NumberField source="visitCount" /> + {isDesktop && ( + <DateField source="lastVisitedAt" showTime sortByOrder={'DESC'} /> + )} + <DateField source="expiresAt" showTime /> + </Datagrid> + )} + </List> + ) +} + +export default ShareList diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx new file mode 100644 index 0000000..a3a15e5 --- /dev/null +++ b/ui/src/share/SharePlayer.jsx @@ -0,0 +1,67 @@ +import ReactJkMusicPlayer from 'navidrome-music-player' +import config, { shareInfo } from '../config' +import { shareCoverUrl, shareDownloadUrl, shareStreamUrl } from '../utils' + +import { makeStyles } from '@material-ui/core/styles' + +const useStyle = makeStyles({ + player: { + '& .group .next-audio': { + pointerEvents: (props) => props.single && 'none', + opacity: (props) => props.single && 0.65, + }, + '@media (min-width: 768px)': { + '& .react-jinke-music-player-mobile > div': { + width: 768, + margin: 'auto', + }, + '& .react-jinke-music-player-mobile-cover': { + width: 'auto !important', + }, + }, + }, +}) + +const SharePlayer = () => { + 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), + singer: s.artist, + duration: s.duration, + } + }) + const onBeforeAudioDownload = () => { + return Promise.resolve({ + src: shareDownloadUrl(shareInfo?.id), + }) + } + const options = { + audioLists: list, + mode: 'full', + toggleMode: false, + mobileMediaQuery: '', + showDownload: shareInfo?.downloadable && config.enableDownloads, + showReload: false, + showMediaSession: true, + theme: 'auto', + showThemeSwitch: false, + restartCurrentOnPrev: true, + remove: false, + spaceBar: true, + volumeFade: { fadeIn: 200, fadeOut: 200 }, + sortableOptions: { delay: 200, delayOnTouchOnly: true }, + } + return ( + <ReactJkMusicPlayer + {...options} + className={classes.player} + onBeforeAudioDownload={onBeforeAudioDownload} + /> + ) +} + +export default SharePlayer diff --git a/ui/src/share/index.jsx b/ui/src/share/index.jsx new file mode 100644 index 0000000..95237fb --- /dev/null +++ b/ui/src/share/index.jsx @@ -0,0 +1,18 @@ +import ShareList from './ShareList' +import { ShareEdit } from './ShareEdit' +import ShareIcon from '@material-ui/icons/Share' +import ShareOutlinedIcon from '@material-ui/icons/ShareOutlined' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' +import React from 'react' + +export default { + list: ShareList, + edit: ShareEdit, + icon: ( + <DynamicMenuIcon + path={'share'} + icon={ShareOutlinedIcon} + activeIcon={ShareIcon} + /> + ), +} diff --git a/ui/src/song/AlbumLinkField.jsx b/ui/src/song/AlbumLinkField.jsx new file mode 100644 index 0000000..3c00c62 --- /dev/null +++ b/ui/src/song/AlbumLinkField.jsx @@ -0,0 +1,30 @@ +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.jsx new file mode 100644 index 0000000..f067e11 --- /dev/null +++ b/ui/src/song/SongList.jsx @@ -0,0 +1,250 @@ +import { useMemo } from 'react' +import { + AutocompleteArrayInput, + Filter, + FunctionField, + NumberField, + ReferenceArrayInput, + SearchInput, + TextField, + useTranslate, + NullableBooleanInput, + usePermissions, +} from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import FavoriteIcon from '@material-ui/icons/Favorite' +import { + DateField, + DurationField, + List, + SongContextMenu, + SongDatagrid, + SongInfo, + QuickFilter, + SongTitleField, + SongSimpleList, + RatingField, + useResourceRefresh, + ArtistLinkField, + PathField, +} from '../common' +import { useDispatch } from 'react-redux' +import { makeStyles } from '@material-ui/core/styles' +import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder' +import { setTrack } from '../actions' +import { SongListActions } from './SongListActions' +import { AlbumLinkField } from './AlbumLinkField' +import { SongBulkActions, QualityInfo, useSelectedFields } from '../common' +import config from '../config' +import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' + +const useStyles = makeStyles({ + contextHeader: { + marginLeft: '3px', + marginTop: '-2px', + verticalAlign: 'text-top', + }, + row: { + '&:hover': { + '& $contextMenu': { + visibility: 'visible', + }, + '& $ratingField': { + visibility: 'visible', + }, + }, + }, + contextMenu: { + visibility: 'hidden', + }, + 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 + label={translate('resources.song.fields.genre')} + source="genre_id" + reference="genre" + perPage={0} + 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> + {config.enableFavourites && ( + <QuickFilter + source="starred" + label={<FavoriteIcon fontSize={'small'} />} + defaultValue={true} + /> + )} + {isAdmin && <NullableBooleanInput source="missing" />} + </Filter> + ) +} + +const SongList = (props) => { + const classes = useStyles() + const dispatch = useDispatch() + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) + useResourceRefresh('song') + + const handleRowClick = (id, basePath, record) => { + dispatch(setTrack(record)) + } + + const toggleableFields = useMemo(() => { + return { + album: isDesktop && <AlbumLinkField source="album" sortByOrder={'ASC'} />, + artist: <ArtistLinkField source="artist" />, + albumArtist: <ArtistLinkField source="albumArtist" />, + trackNumber: isDesktop && <NumberField source="trackNumber" />, + playCount: isDesktop && ( + <NumberField source="playCount" sortByOrder={'DESC'} /> + ), + playDate: <DateField source="playDate" sortByOrder={'DESC'} showTime />, + year: isDesktop && ( + <FunctionField + source="year" + render={(r) => r.year || ''} + sortByOrder={'DESC'} + /> + ), + quality: isDesktop && <QualityInfo source="quality" sortable={false} />, + channels: isDesktop && ( + <NumberField source="channels" sortByOrder={'ASC'} /> + ), + duration: <DurationField source="duration" />, + rating: config.enableStarRating && ( + <RatingField + source="rating" + sortByOrder={'DESC'} + resource={'song'} + className={classes.ratingField} + /> + ), + 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 /> + ), + } + }, [isDesktop, classes.ratingField]) + + const columns = useSelectedFields({ + resource: 'song', + columns: toggleableFields, + defaultOff: [ + 'channels', + 'bpm', + 'playDate', + 'albumArtist', + 'genre', + 'mood', + 'comment', + 'path', + 'createdAt', + ], + }) + + return ( + <> + <List + {...props} + sort={{ field: 'title', order: 'ASC' }} + exporter={false} + bulkActionButtons={<SongBulkActions />} + actions={<SongListActions />} + filters={<SongFilter />} + perPage={isXsmall ? 50 : 15} + > + {isXsmall ? ( + <SongSimpleList /> + ) : ( + <SongDatagrid + rowClick={handleRowClick} + contextAlwaysVisible={!isDesktop} + classes={{ row: classes.row }} + > + <SongTitleField source="title" showTrackNumbers={false} /> + {columns} + <SongContextMenu + source={'starred_at'} + sortByOrder={'DESC'} + sortable={config.enableFavourites} + className={classes.contextMenu} + label={ + config.enableFavourites && ( + <FavoriteBorderIcon + fontSize={'small'} + className={classes.contextHeader} + /> + ) + } + /> + </SongDatagrid> + )} + </List> + <ExpandInfoDialog content={<SongInfo />} /> + </> + ) +} + +export default SongList diff --git a/ui/src/song/SongListActions.jsx b/ui/src/song/SongListActions.jsx new file mode 100644 index 0000000..44fe9b5 --- /dev/null +++ b/ui/src/song/SongListActions.jsx @@ -0,0 +1,44 @@ +import React, { cloneElement } from 'react' +import { sanitizeListRestProps, TopToolbar } from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { ShuffleAllButton, ToggleFieldsMenu } from '../common' + +export const SongListActions = ({ + currentSort, + className, + resource, + filters, + displayedFilters, + filterValues, + permanentFilter, + exporter, + basePath, + selectedIds, + onUnselectItems, + showFilter, + maxResults, + total, + ids, + ...rest +}) => { + const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm')) + return ( + <TopToolbar className={className} {...sanitizeListRestProps(rest)}> + <ShuffleAllButton filters={filterValues} /> + {filters && + cloneElement(filters, { + resource, + showFilter, + displayedFilters, + filterValues, + context: 'button', + })} + {isNotSmall && <ToggleFieldsMenu resource="song" />} + </TopToolbar> + ) +} + +SongListActions.defaultProps = { + selectedIds: [], + onUnselectItems: () => null, +} diff --git a/ui/src/song/index.jsx b/ui/src/song/index.jsx new file mode 100644 index 0000000..bbd3b20 --- /dev/null +++ b/ui/src/song/index.jsx @@ -0,0 +1,16 @@ +import React from 'react' +import SongList from './SongList' +import MusicNoteOutlinedIcon from '@material-ui/icons/MusicNoteOutlined' +import MusicNoteIcon from '@material-ui/icons/MusicNote' +import DynamicMenuIcon from '../layout/DynamicMenuIcon' + +export default { + list: SongList, + icon: ( + <DynamicMenuIcon + path={'song'} + icon={MusicNoteOutlinedIcon} + activeIcon={MusicNoteIcon} + /> + ), +} diff --git a/ui/src/store/createAdminStore.js b/ui/src/store/createAdminStore.js new file mode 100644 index 0000000..4888e49 --- /dev/null +++ b/ui/src/store/createAdminStore.js @@ -0,0 +1,77 @@ +import { + applyMiddleware, + combineReducers, + compose, + legacy_createStore as createStore, +} from 'redux' +import { routerMiddleware, connectRouter } from 'connected-react-router' +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 { loadState, saveState } from './persistState' + +const createAdminStore = ({ + authProvider, + dataProvider, + history, + customReducers = {}, +}) => { + const reducer = combineReducers({ + admin: adminReducer, + router: connectRouter(history), + ...customReducers, + }) + const resettableAppReducer = (state, action) => + reducer(action.type !== USER_LOGOUT ? state : undefined, action) + + const saga = function* rootSaga() { + yield all([adminSaga(dataProvider, authProvider)].map(fork)) + } + const sagaMiddleware = createSagaMiddleware() + + const composeEnhancers = + (process.env.NODE_ENV === 'development' && + typeof window !== 'undefined' && + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ && + window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({ + trace: true, + traceLimit: 25, + })) || + compose + + const persistedState = loadState() + if (persistedState?.player?.savedPlayIndex) { + persistedState.player.playIndex = persistedState.player.savedPlayIndex + } + const store = createStore( + resettableAppReducer, + persistedState, + composeEnhancers( + applyMiddleware(sagaMiddleware, routerMiddleware(history)), + ), + ) + + store.subscribe( + throttle(() => { + const state = store.getState() + saveState({ + theme: state.theme, + library: state.library, + player: (({ queue, volume, savedPlayIndex }) => ({ + queue, + volume, + savedPlayIndex, + }))(state.player), + albumView: state.albumView, + settings: state.settings, + }) + }), + 1000, + ) + + sagaMiddleware.run(saga) + return store +} + +export default createAdminStore diff --git a/ui/src/store/persistState.js b/ui/src/store/persistState.js new file mode 100644 index 0000000..992877a --- /dev/null +++ b/ui/src/store/persistState.js @@ -0,0 +1,20 @@ +export const loadState = () => { + try { + const serializedState = localStorage.getItem('state') + if (serializedState === null) { + return undefined + } + return JSON.parse(serializedState) + } catch (err) { + return undefined + } +} + +export const saveState = (state) => { + try { + const serializedState = JSON.stringify(state) + localStorage.setItem('state', serializedState) + } catch (err) { + // Ignore write errors + } +} diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js new file mode 100644 index 0000000..cfcc010 --- /dev/null +++ b/ui/src/subsonic/index.js @@ -0,0 +1,138 @@ +import { baseUrl } from '../utils' +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('f', 'json') + params.append('v', '1.8.0') + params.append('c', 'NavidromeUI') + id && params.append('id', id) + if (options) { + if (options.ts) { + options['_'] = new Date().getTime() + 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) + } + }) + } + return `/rest/${command}?${params.toString()}` +} + +const ping = () => httpClient(url('ping')) + +const scrobble = (id, time, submission = true, position = null) => + httpClient( + url('scrobble', id, { + ...(submission && time && { time }), + submission, + ...(!submission && position !== null && { position }), + }), + ) + +const nowPlaying = (id, position = null) => scrobble(id, null, false, position) + +const star = (id) => httpClient(url('star', id)) + +const unstar = (id) => httpClient(url('unstar', id)) + +const setRating = (id, rating) => httpClient(url('setRating', id, { rating })) + +const download = (id, format = 'raw', bitrate = '0') => + (window.location.href = baseUrl(url('download', id, { format, bitrate }))) + +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 options = { + ...(record.updatedAt && { _: record.updatedAt }), + ...(size && { size }), + ...(square && { square }), + } + + // TODO Move this logic to server + if (record.album) { + return baseUrl(url('getCoverArt', 'mf-' + record.id, options)) + } else if (record.albumArtist) { + return baseUrl(url('getCoverArt', 'al-' + record.id, options)) + } else if (record.sync !== undefined) { + // This is a playlist + return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) + } else { + return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) + } +} + +const getArtistInfo = (id) => { + return httpClient(url('getArtistInfo', id)) +} + +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, + scrobble, + nowPlaying, + download, + star, + unstar, + setRating, + startScan, + getScanStatus, + getNowPlaying, + getCoverArtUrl, + getAvatarUrl, + streamUrl, + getAlbumInfo, + getArtistInfo, + getTopSongs, + getSimilarSongs2, +} diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js new file mode 100644 index 0000000..1e0fbea --- /dev/null +++ b/ui/src/subsonic/index.test.js @@ -0,0 +1,129 @@ +import { vi } from 'vitest' +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, 300, true) + + expect(url).toContain('pl-playlist-123') + expect(url).toContain('size=300') + 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, 300, true) + + expect(url).toContain('pl-playlist-123') + expect(url).toContain('size=300') + 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, 300, true) + + expect(url).toContain('al-album-123') + expect(url).toContain('size=300') + 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, 300, true) + + expect(url).toContain('mf-song-123') + expect(url).toContain('size=300') + 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, 300, true) + + expect(url).toContain('ar-artist-123') + expect(url).toContain('size=300') + 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('_=') + }) +}) + +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') + }) +}) diff --git a/ui/src/sw.js b/ui/src/sw.js new file mode 100644 index 0000000..f4a5664 --- /dev/null +++ b/ui/src/sw.js @@ -0,0 +1,57 @@ +/* eslint-disable */ + +// 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 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, + }) + } +} + +// 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/themes/README.md b/ui/src/themes/README.md new file mode 100644 index 0000000..a514801 --- /dev/null +++ b/ui/src/themes/README.md @@ -0,0 +1,2 @@ +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 new file mode 100644 index 0000000..2c8e4f1 --- /dev/null +++ b/ui/src/themes/SquiddiesGlass.css.js @@ -0,0 +1,175 @@ +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 new file mode 100644 index 0000000..5c38440 --- /dev/null +++ b/ui/src/themes/SquiddiesGlass.js @@ -0,0 +1,608 @@ +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': { + filter: + 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%);', + }, + '& img + 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 new file mode 100644 index 0000000..9430a6c --- /dev/null +++ b/ui/src/themes/amusic.css.js @@ -0,0 +1,87 @@ +const stylesheet = ` +.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, +.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 new file mode 100644 index 0000000..4181d17 --- /dev/null +++ b/ui/src/themes/amusic.js @@ -0,0 +1,224 @@ +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', + }, + 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', + }, + }, + RaDeleteWithConfirmButton: { + deleteButton: { + color: 'unset', + }, + }, + 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/catppuccinMacchiato.css.js b/ui/src/themes/catppuccinMacchiato.css.js new file mode 100644 index 0000000..d303a03 --- /dev/null +++ b/ui/src/themes/catppuccinMacchiato.css.js @@ -0,0 +1,154 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #00a4dc +} + +.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: #00a4dc +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #00a4dc; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #00a4dc +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #00a4dc +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #00a4dc !important +} + +.react-jinke-music-player-main .loading svg { + color: #00a4dc !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; + color: #24273a + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); +} + +.audio-lists-panel { + background-color: #1e2030; + 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 #242936; +} + +.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: #acb3d2; + -webkit-text-stroke: 0.5px #2E3440; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #acb3d2 !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: #00a4dc +} + +.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: #00a4dc +} + +.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: #363a4f; +} + +/* 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: #00a4dc; +} + +.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: #00a4dc; +} + +.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 new file mode 100644 index 0000000..63c93ff --- /dev/null +++ b/ui/src/themes/catppuccinMacchiato.js @@ -0,0 +1,106 @@ +import stylesheet from './catppuccinMacchiato.css.js' + +export default { + themeName: 'Catppuccin Macchiato', + palette: { + primary: { + main: '#c6a0f6', + }, + secondary: { + main: '#1e2030', + contrastText: '#cad3f5', + }, + type: 'dark', + background: { + default: '#24273a', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#cad3f5', + backgroundColor: '#1e2030', + MuiSnackbarContent: { + root: { + color: '#cad3f5', + backgroundColor: '#ed8796', + }, + message: { + color: '#cad3f5', + backgroundColor: '#ed8796', + }, + }, + }, + }, + MuiButton: { + textPrimary: { + color: '#8aadf4', + }, + textSecondary: { + color: '#cad3f5', + }, + }, + MuiChip: { + clickable: { + background: '#1e2030', + }, + }, + MuiFormGroup: { + root: { + color: '#cad3f5', + }, + }, + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#ed8796', + }, + }, + }, + }, + MuiTableHead: { + root: { + color: '#cad3f5', + background: '#1e2030', + }, + }, + MuiTableCell: { + root: { + color: '#cad3f5', + background: '#1e2030 !important', + }, + head: { + color: '#cad3f5', + background: '#1e2030 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#c6a0f6', + }, + icon: {}, + welcome: { + color: '#cad3f5', + }, + card: { + minWidth: 300, + background: '#24273a', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #24273a', + }, + }, + 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 new file mode 100644 index 0000000..7cbb19c --- /dev/null +++ b/ui/src/themes/dark.css.js @@ -0,0 +1,35 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #7171d5 +} + +.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: #5f5fc4 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #5f5fc4; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #5f5fc4 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #5f5fc4 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #5f5fc4 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #5f5fc4 +} +.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 new file mode 100644 index 0000000..15d8aa3 --- /dev/null +++ b/ui/src/themes/dark.js @@ -0,0 +1,52 @@ +import blue from '@material-ui/core/colors/blue' +import stylesheet from './dark.css.js' + +export default { + themeName: 'Dark', + palette: { + primary: { + main: '#90caf9', + }, + secondary: blue, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: 'white', + }, + }, + MuiButton: { + textPrimary: { + color: '#fff', + }, + }, + NDLogin: { + systemNameLink: { + color: '#0085ff', + }, + icon: {}, + welcome: { + color: '#eee', + }, + card: { + minWidth: 300, + backgroundColor: '#424242ed', + }, + avatar: {}, + 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/electricPurple.css.js b/ui/src/themes/electricPurple.css.js new file mode 100644 index 0000000..fd64d17 --- /dev/null +++ b/ui/src/themes/electricPurple.css.js @@ -0,0 +1,39 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #bd4aff; +} + +.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: #8800cb; +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #8800cb; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #8800cb; +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #8800cb; +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #8800cb !important; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #8800cb; +} +.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: #8800cb; +} + +.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 new file mode 100644 index 0000000..108c8e5 --- /dev/null +++ b/ui/src/themes/electricPurple.js @@ -0,0 +1,57 @@ +import stylesheet from './electricPurple.css.js' + +export default { + themeName: 'Electric Purple', + palette: { + primary: { + light: '#f757ff', + dark: '#8800cb', + main: '#bf00ff', + contrastText: '#fff', + }, + secondary: { + light: '#bd4aff', + dark: '#530099', + main: '#8800cb', + contrastText: '#fff', + }, + warn: { + light: '#ffff82', + dark: '#c9bf07', + main: '#fff14e', + contrastText: '#000', + }, + error: { + light: '#ff763a', + dark: '#c30000', + main: '#ff3f00', + contrastText: '#000', + }, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: 'white', + }, + }, + NDLogin: { + systemNameLink: { + color: '#fff', + }, + welcome: { + color: '#eee', + }, + }, + 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/extradark.js b/ui/src/themes/extradark.js new file mode 100644 index 0000000..ef71403 --- /dev/null +++ b/ui/src/themes/extradark.js @@ -0,0 +1,44 @@ +import blue from '@material-ui/core/colors/blue' +import stylesheet from './dark.css.js' + +export default { + themeName: 'Extra Dark', + palette: { + background: { + paper: '#000000', + default: '#000000', + }, + primary: { + main: '#0f60b6', + contrastText: '#909090', + }, + secondary: blue, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: 'white', + }, + }, + NDLogin: { + systemNameLink: { + color: '#fff', + }, + welcome: { + color: '#eee', + }, + }, + NDArtistPage: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(0 0 0))!important', + }, + }, + }, + + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/green.js b/ui/src/themes/green.js new file mode 100644 index 0000000..e7709ad --- /dev/null +++ b/ui/src/themes/green.js @@ -0,0 +1,40 @@ +import green from '@material-ui/core/colors/green' + +export default { + themeName: 'Green', + palette: { + primary: { + light: green['300'], + main: green['500'], + }, + secondary: { + main: green['900'], + contrastText: '#fff', + }, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: 'white', + }, + }, + NDLogin: { + systemNameLink: { + color: '#fff', + }, + welcome: { + color: '#eee', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', + }, + }, + }, + player: { + theme: 'dark', + }, +} diff --git a/ui/src/themes/gruvboxDark.css.js b/ui/src/themes/gruvboxDark.css.js new file mode 100644 index 0000000..dc1f640 --- /dev/null +++ b/ui/src/themes/gruvboxDark.css.js @@ -0,0 +1,55 @@ +const stylesheet = ` + +.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: #458588 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #458588; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #458588 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #458588 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #458588 !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #ebdbb2 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #458588 !important +} + +.music-player-lyric { + color: #458588 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #458588 +} +.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: #458588 +} + +.progress-bar-content .audio-title a { + color: #ebdbb2 +} + +.MuiCheckbox-colorSecondary.Mui-checked { + color: #458588 !important +} +` + +export default stylesheet diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js new file mode 100644 index 0000000..b1a2e4c --- /dev/null +++ b/ui/src/themes/gruvboxDark.js @@ -0,0 +1,111 @@ +import stylesheet from './gruvboxDark.css.js' + +export default { + themeName: 'Gruvbox Dark', + palette: { + primary: { + main: '#8ec07c', + }, + secondary: { + main: '#3c3836', + contrastText: '#ebdbb2', + }, + type: 'dark', + background: { + default: '#282828', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#ebdbb2', + backgroundColor: '#3c3836', + MuiSnackbarContent: { + root: { + color: '#ebdbb2', + backgroundColor: '#cc241d', + }, + message: { + color: '#ebdbb2', + backgroundColor: '#cc241d', + }, + }, + }, + }, + MuiButton: { + textPrimary: { + color: '#458588', + }, + textSecondary: { + color: '#ebdbb2', + }, + }, + MuiIconButton: { + root: { + color: '#ebdbb2', + }, + }, + MuiChip: { + clickable: { + background: '#49483e', + }, + }, + MuiFormGroup: { + root: { + color: '#ebdbb2', + }, + }, + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#cc241d', + }, + }, + }, + }, + MuiTableHead: { + root: { + color: '#ebdbb2', + background: '#3c3836 !important', + }, + }, + MuiTableCell: { + root: { + color: '#ebdbb2', + background: '#3c3836 !important', + }, + head: { + color: '#ebdbb2', + background: '#3c3836 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#8ec07c', + }, + icon: {}, + welcome: { + color: '#ebdbb2', + }, + card: { + minWidth: 300, + background: '#3c3836', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #3c3836', + }, + }, + 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/index.js b/ui/src/themes/index.js new file mode 100644 index 0000000..5f90603 --- /dev/null +++ b/ui/src/themes/index.js @@ -0,0 +1,34 @@ +import LightTheme from './light' +import DarkTheme from './dark' +import ExtraDarkTheme from './extradark' +import GreenTheme from './green' +import SpotifyTheme from './spotify' +import LigeraTheme from './ligera' +import MonokaiTheme from './monokai' +import ElectricPurpleTheme from './electricPurple' +import NordTheme from './nord' +import GruvboxDarkTheme from './gruvboxDark' +import CatppuccinMacchiatoTheme from './catppuccinMacchiato' +import NuclearTheme from './nuclear' +import AmusicTheme from './amusic' +import SquiddiesGlassTheme from './SquiddiesGlass' + +export default { + // Classic default themes + LightTheme, + DarkTheme, + + // New themes should be added here, in alphabetic order + AmusicTheme, + CatppuccinMacchiatoTheme, + ElectricPurpleTheme, + ExtraDarkTheme, + GreenTheme, + GruvboxDarkTheme, + LigeraTheme, + MonokaiTheme, + NordTheme, + NuclearTheme, + SpotifyTheme, + SquiddiesGlassTheme, +} diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js new file mode 100644 index 0000000..363a379 --- /dev/null +++ b/ui/src/themes/ligera.js @@ -0,0 +1,492 @@ +const bLight = { + 300: '#0054df', + 500: '#ffffff', +} +const musicListActions = { + padding: '1rem 0', + alignItems: 'center', + '@global': { + button: { + margin: 5, + border: '1px solid #cccccc', + backgroundColor: '#fff', + color: '#b3b3b3', + '&:hover': { + border: '1px solid #224bff', + backgroundColor: 'inherit !important', + }, + }, + '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', + background: bLight['500'], + color: '#fff', + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${bLight['500']} !important`, + border: 0, + boxShadow: '0px 0px 4px 0px #5656567d', + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + color: bLight['300'], + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: '#656565', + }, + }, +} + +export default { + themeName: 'Ligera', + palette: { + primary: { + light: bLight['300'], + main: '#464646', + }, + secondary: { + main: '#000', + contrastText: '#fff', + }, + background: { + default: '#f0f2f5', + paper: bLight['500'], + }, + text: { + secondary: '#232323', + }, + }, + typography: { + fontFamily: "system-ui, 'Helvetica Neue', Helvetica, Arial", + h6: { + fontSize: '1rem', + }, + }, + overrides: { + MuiAutocomplete: { + popper: { + background: bLight['500'], + }, + }, + MuiCard: { + root: { + marginLeft: '1%', + marginRight: '1%', + background: bLight['500'], + }, + }, + MuiPopover: { + paper: { + backgroundColor: bLight['500'], + '& .MuiListItemIcon-root': { + color: '#656565', + }, + }, + }, + MuiTypography: { + colorTextSecondary: { + color: '#0a0a0a', + }, + }, + MuiDialog: { + paper: { + backgroundColor: bLight['500'], + }, + }, + MuiFormGroup: { + root: { + color: '#464646', + }, + }, + MuiMenuItem: { + root: { + fontSize: '0.875rem', + }, + }, + MuiDivider: { + root: { + margin: '.75rem 0', + }, + }, + MuiFormLabel: { + root: { + color: '#91b1b0', + }, + }, + MuiCheckbox: { + root: { + color: '#616161', + }, + }, + MuiIconButton: { + label: {}, + }, + MuiButton: { + root: { + background: '#fff', + color: '#000', + border: '1px solid transparent', + borderRadius: 500, + '&:hover': { + background: `${bLight['300']} !important`, + color: '#fff', + }, + }, + containedPrimary: { + backgroundColor: '#fff', + }, + textPrimary: { + backgroundColor: bLight['300'], + '& span': { + color: '#fff', + }, + '&:hover': { + backgroundColor: '#3079ff !important', + }, + }, + textSecondary: { + border: '1px solid #b3b3b3', + background: '#fff', + '&:hover': { + border: '1px solid #fff !important', + background: '#dedede !important', + }, + }, + label: { + color: '#000', + paddingRight: '1rem', + paddingLeft: '0.7rem', + }, + }, + MuiDrawer: { + root: { + background: bLight['500'], + paddingTop: '10px', + boxShadow: '-14px -7px 20px black', + }, + }, + MuiTableRow: { + root: { + padding: '10px 0', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: '#e4e4e4 !important', + }, + '@global': { + 'td:nth-child(4)': { + color: '#3c3c3c !important', + }, + }, + }, + head: { + backgroundColor: '#e0efff', + }, + }, + MuiTableCell: { + root: { + borderBottom: '1px solid #1d1d1d', + padding: '10px !important', + color: '#656565 !important', + }, + head: { + borderBottom: '1px solid #282828', + fontSize: '0.75rem', + textTransform: 'uppercase', + letterSpacing: 1.2, + }, + }, + MuiAppBar: { + positionFixed: { + background: `${bLight['500']} !important`, + boxShadow: '13px -12px 20px 0px #000', + }, + colorSecondary: { + color: bLight['300'], + }, + }, + NDAppBar: { + icon: { + color: '#fff', + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: '#000000b0', + }, + albumSubtitle: { + color: '#000000ad', + display: 'block', + }, + albumContainer: { + backgroundColor: '#e0efff7d', + borderRadius: '.5rem', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: '#c6dbff', + }, + }, + albumPlayButton: { + backgroundColor: bLight['500'], + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + color: bLight['300'], + '&:hover': { + background: `${bLight['300']} !important`, + padding: '0.45rem', + color: bLight['500'], + }, + }, + }, + NDPlaylistDetails: { + container: { + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontSize: 'calc(1.5rem + 1.5vw);', + fontWeight: 700, + color: '#000000b0', + }, + details: { + fontSize: '.875rem', + color: 'rgba(255,255,255, 0.8)', + }, + }, + NDAlbumDetails: { + root: { + borderRadius: 0, + boxShadow: '-1px 1px 6px 0px #00000057', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontSize: 'calc(1rem + 1.5vw);', + fontWeight: 700, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + }, + recordMeta: { + fontSize: '.875rem', + color: 'rgb(113 113 113 / 80%)', + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: 'rgb(113 113 113 / 80%)', + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDSubMenu: { + icon: { + color: '#656565', + }, + }, + NDAudioPlayer: { + audioTitle: { + color: '#000', + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: '#b3b3b3', + }, + player: {}, + }, + NDLogin: { + actions: { + '& button': { + backgroundColor: '#3c9cff', + }, + }, + systemNameLink: { + textDecoration: 'none', + color: bLight['300'], + }, + systemName: { + marginTop: '0.5em', + marginBottom: '1em', + }, + icon: { + backgroundColor: 'transparent', + width: '100px', + height: '100px', + }, + card: { + minWidth: 300, + marginTop: '6em', + overflow: 'visible', + backgroundColor: '#ffffffe6', + }, + avatar: { + marginTop: '-50px', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgb(255 255 255 / 51%), rgb(240 242 245))!important', + }, + }, + RaLayout: { + content: { + padding: '0 !important', + }, + }, + RaListToolbar: { + toolbar: { + padding: '0 .55rem !important', + }, + }, + RaDatagridHeaderCell: { + icon: { + color: '#717171 !important', + }, + }, + RaSearchInput: { + input: { + paddingLeft: '.9rem', + border: 0, + }, + }, + RaFilterButton: { + root: { + marginRight: '1rem', + '& button': { + color: '#0f0f0f', + backgroundColor: '#fff', + '& span': { + color: '#101010', + }, + '&:hover': { + backgroundColor: '#dedede !important', + }, + }, + }, + }, + RaAutocompleteSuggestionList: { + suggestionsPaper: { + backgroundColor: '#fff', + }, + }, + RaLink: { + link: { + color: '#287eff', + }, + }, + RaLogout: { + icon: { + color: '#f90000!important', + }, + }, + RaMenuItemLink: { + root: { + color: '#232323 !important', + '& .MuiListItemIcon-root': { + color: '#656565', + }, + }, + active: { + backgroundColor: '#44a0ff1f', + color: '#232323 !important', + '& .MuiListItemIcon-root': { + color: '#0066ff', + }, + }, + }, + RaSidebar: { + root: { + height: 'initial', + }, + drawerPaper: { + '@media (min-width: 0px) and (max-width: 599.95px)': { + backgroundColor: `${bLight['500']} !important`, + }, + }, + }, + RaBulkActionsToolbar: { + toolbar: { + backgroundColor: bLight['500'], + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + button: { + backgroundColor: '#fff', + color: '#000', + minWidth: 48, + margin: '0 4px', + border: '1px solid #cccccc', + '@global': { + '> .MuiButton-label': { + padding: 0, + color: '#656565', + '&:hover': { + color: '#fff !important', + }, + }, + '> .MuiButton-label > svg': { + color: '#656565', + }, + }, + }, + actions: { + '@global': { + '.next-page': { + marginLeft: 8, + marginRight: 8, + }, + '.previous-page': { + marginRight: 8, + }, + }, + }, + }, + }, + player: { + theme: 'light', + }, +} diff --git a/ui/src/themes/light.css.js b/ui/src/themes/light.css.js new file mode 100644 index 0000000..a084cf1 --- /dev/null +++ b/ui/src/themes/light.css.js @@ -0,0 +1,122 @@ +const stylesheet = ` +.react-jinke-music-player-main.light-theme .loading svg { + color: #5f5fc4; + font-size: 24px +} + +.react-jinke-music-player-mobile-play-model-tip { + background-color: #5f5fc4; +} + +.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: #5f5fc4 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #5f5fc4 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing svg { + color: #5f5fc4 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer { + color: #5f5fc4 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #5f5fc4 +} +.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 +} + +.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb { + background-color: #5f5fc4; +} + +.react-jinke-music-player-main.light-theme svg { + color: #5f5fc4 +} + +.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { + color: #7171d5 +} + +.react-jinke-music-player-main.light-theme .rc-slider-rail { + background-color: rgba(0, 0, 0, .09) !important +} + +.react-jinke-music-player-main.light-theme .music-player-controller { + background-color: #fff; + border-color: #fff +} + +.react-jinke-music-player-main.light-theme .music-player-panel { + background-color: #fff; + box-shadow: 0 1px 2px 0 rgba(0, 34, 77, .05); + color: #5f5fc4 +} + +.react-jinke-music-player-main.light-theme .music-player-panel .img-content { + box-shadow: 0 0 10px #dcdcdc +} + +.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar { + background-color: rgba(0, 0, 0, .06) !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: #5f5fc4 !important; + border: 1px solid #5f5fc4 +} + +.react-jinke-music-player-main.light-theme .rc-switch-inner { + color: #fff +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn { + background-color: #f7f8fa !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: #fdfdfe; + color: #444 +} + +.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: #444 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel { + background-color: #fff; + box-shadow: 0 0 2px #dcdcdc; + color: #444 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item { + background-color: #fff +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) { + background-color: #fafafa !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing { + background-color: #f2f2f2 !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: #5f5fc4 !important +} +` + +export default stylesheet diff --git a/ui/src/themes/light.js b/ui/src/themes/light.js new file mode 100644 index 0000000..63a32bf --- /dev/null +++ b/ui/src/themes/light.js @@ -0,0 +1,62 @@ +import stylesheet from './light.css.js' + +export default { + themeName: 'Light', + palette: { + secondary: { + light: '#5f5fc4', + dark: '#001064', + main: '#3f51b5', + contrastText: '#fff', + }, + }, + overrides: { + MuiFilledInput: { + root: { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + '&$disabled': { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + }, + }, + }, + NDLogin: { + main: { + '& .MuiFormLabel-root': { + color: '#000000', + }, + '& .MuiFormLabel-root.Mui-focused': { + color: '#0085ff', + }, + '& .MuiFormLabel-root.Mui-error': { + color: '#f44336', + }, + '& .MuiInput-underline:after': { + borderBottom: '2px solid #0085ff', + }, + }, + card: { + minWidth: 300, + marginTop: '6em', + backgroundColor: '#ffffffe6', + }, + avatar: {}, + icon: {}, + button: { + boxShadow: '3px 3px 5px #000000a3', + }, + systemNameLink: { + color: '#0085ff', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgb(255 255 255 / 51%), rgb(250 250 250))!important', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/monokai.css.js b/ui/src/themes/monokai.css.js new file mode 100644 index 0000000..12227c9 --- /dev/null +++ b/ui/src/themes/monokai.css.js @@ -0,0 +1,51 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #fd971f +} + +.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: #fd971f +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #fd971f; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #fd971f +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #fd971f +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #fd971f !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #f8f8f2 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #fd971f !important +} + +.music-player-lyric { + color: #fd971f !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #fd971f +} +.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: #fd971f +} + +.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 new file mode 100644 index 0000000..9e4dd7f --- /dev/null +++ b/ui/src/themes/monokai.js @@ -0,0 +1,106 @@ +import stylesheet from './monokai.css.js' + +export default { + themeName: 'Monokai', + palette: { + primary: { + main: '#66d9ef', + }, + secondary: { + main: '#49483e', + contrastText: '#f8f8f2', + }, + type: 'dark', + background: { + default: '#272822', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#f8f8f2', + backgroundColor: '#3b3a32', + MuiSnackbarContent: { + root: { + color: '#f8f8f2', + backgroundColor: '#f92672', + }, + message: { + color: '#f8f8f2', + backgroundColor: '#f92672', + }, + }, + }, + }, + MuiButton: { + textPrimary: { + color: '#66d9ef', + }, + textSecondary: { + color: '#f8f8f2', + }, + }, + MuiChip: { + clickable: { + background: '#49483e', + }, + }, + MuiFormGroup: { + root: { + color: '#f8f8f2', + }, + }, + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#f92672', + }, + }, + }, + }, + MuiTableHead: { + root: { + color: '#f8f8f2', + background: '#3b3a32 !important', + }, + }, + MuiTableCell: { + root: { + color: '#f8f8f2', + background: '#3b3a32 !important', + }, + head: { + color: '#f8f8f2', + background: '#3b3a32 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#66d9ef', + }, + icon: {}, + welcome: { + color: '#f8f8f2', + }, + card: { + minWidth: 300, + background: '#3b3a32', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #272822', + }, + }, + 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/nord.css.js b/ui/src/themes/nord.css.js new file mode 100644 index 0000000..0634c23 --- /dev/null +++ b/ui/src/themes/nord.css.js @@ -0,0 +1,168 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #81A1C1 +} + +.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: #5E81AC +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #5E81AC; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #5E81AC +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #5E81AC +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #5E81AC !important +} + +.react-jinke-music-player-main .loading svg { + color: #5E81AC !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 { + border-radius: 1rem; + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.progress-bar > div:nth-child(2) > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; +} + +.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 .panel-content .player-content .play-sounds .sound-operation { + width: 50px; +} + +.react-jinke-music-player-main .music-player-panel { + background-color: #2E3440; + color: #ECEFF4 + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); +} + +.audio-lists-panel { + background-color: #2E3440; + border-radius: .625rem; + 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 #242936; +} + +.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; + padding: 4px 20px; + border-radius: 8px; + margin: 3px; +} + +.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: #D8DEE9; + -webkit-text-stroke: 0.5px #2E3440; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #D8DEE9 !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: #5E81AC +} + +.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: #5E81AC +} + +.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: #3B4252; +} + +/* 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: #5E81AC; +} + +.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: #5E81AC; +} + +.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 new file mode 100644 index 0000000..5420bbc --- /dev/null +++ b/ui/src/themes/nord.js @@ -0,0 +1,426 @@ +import stylesheet from './nord.css.js' + +// For Album, Playlist +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: '#5E81AC !important', + color: '#fff', + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: '#5E81AC !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: 'rgba(255, 255, 255, 0.8)', + }, + }, +} + +export default { + themeName: 'Nord', + palette: { + primary: { + main: '#D8DEE9', + }, + secondary: { + main: '#4C566A', + }, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: '#D8DEE9', + }, + }, + MuiMenuItem: { + root: { + fontSize: '0.875rem', + paddingTop: '4px', + paddingBottom: '4px', + paddingLeft: '10px', + margin: '5px', + borderRadius: '8px', + }, + }, + MuiDivider: { + root: { + margin: '.75rem 0', + }, + }, + MuiButton: { + root: { + backgroundColor: '#4C566A !important', + border: '1px solid transparent', + borderRadius: 500, + '&:hover': { + backgroundColor: `${'#5E81AC !important'}`, + }, + }, + label: { + color: '#D8DEE9', + paddingRight: '1rem', + paddingLeft: '0.7rem', + }, + contained: { + boxShadow: 'none', + '&:hover': { + boxShadow: 'none', + }, + }, + }, + MuiIconButton: { + label: { + color: '#D8DEE9', + }, + }, + MuiDrawer: { + root: { + background: '#2E3440', + paddingTop: '10px', + }, + }, + + MuiList: { + root: { + color: '#D8DEE9', + background: 'none', + }, + }, + MuiListItem: { + button: { + transition: 'background-color .1s ease !important', + }, + }, + MuiPaper: { + root: { + backgroundColor: '#3B4252', + }, + rounded: { + borderRadius: '8px', + }, + elevation1: { + boxShadow: 'none', + }, + }, + MuiTableRow: { + root: { + color: '#434C5E', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: '#434C5E !important', + }, + '&:last-child': { + borderBottom: '1px solid #4C566A !important', + }, + }, + head: { + color: '#4C566A', + }, + }, + MuiToolbar: { + root: { + backgroundColor: '#3B4252 !important', + }, + }, + MuiTableCell: { + root: { + borderBottom: 'none', + color: '#b3b3b3 !important', + padding: '10px !important', + }, + head: { + borderBottom: '1px solid #2E3440', + fontSize: '0.75rem', + textTransform: 'uppercase', + letterSpacing: 1.2, + backgroundColor: '#4C566A !important', + color: '#D8DEE9 !important', + }, + body: { + color: '#D8DEE9 !important', + }, + }, + MuiSwitch: { + track: { + width: '89%', + transform: 'translateX(.1rem) scale(140%)', + opacity: '0.7 !important', + backgroundColor: 'rgba(255,255,255,0.25)', + }, + thumb: { + transform: 'scale(60%)', + boxShadow: 'none', + }, + }, + RaToolBar: { + regular: { + backgroundColor: 'none !important', + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: '#4C566A !important', + boxShadow: + 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px', + }, + }, + MuiOutlinedInput: { + root: { + borderRadius: '8px', + '&:hover': { + borderColor: '#D8DEE9', + }, + }, + notchedOutline: { + transition: 'border-color .1s', + }, + }, + MuiSelect: { + select: { + '&:focus': { + borderRadius: '8px', + }, + }, + }, + MuiChip: { + root: { + backgroundColor: '#4C566A', + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: '#E5E9F0', + }, + albumSubtitle: { + color: '#b3b3b3', + }, + albumContainer: { + backgroundColor: '#434C5E', + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: '#4C566A', + }, + }, + albumPlayButton: { + backgroundColor: '#5E81AC', + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${'#5E81AC'} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontSize: 'calc(1.5rem + 1.5vw);', + fontWeight: 700, + color: '#fff', + }, + details: { + fontSize: '.875rem', + color: 'rgba(255,255,255, 0.8)', + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAlbumDetails: { + root: { + background: '#434C5E', + borderRadius: 0, + boxShadow: '0 8px 8px rgb(0 0 0 / 20%)', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontSize: 'calc(1rem + 1.5vw);', + fontWeight: 700, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + }, + recordMeta: { + fontSize: '.875rem', + color: 'rgba(255,255,255, 0.8)', + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: 'rgba(255,255,255, 0.8)', + }, + }, + NDAudioPlayer: { + audioTitle: { + color: '#D8DEE9', + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: '#b3b3b3', + }, + player: { + border: '10px solid #4C566A', + backgroundColor: '#4C566A !important', + }, + }, + NDLogin: { + main: { + boxShadow: 'inset 0 0 0 2000px rgba(0, 0, 0, .75)', + }, + systemNameLink: { + color: '#fff', + }, + card: { + border: '1px solid #282828', + }, + avatar: { + marginBottom: 0, + }, + }, + NDSubMenu: { + sidebarIsClosed: { + '& a': { + paddingLeft: '10px', + }, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: '#3B4252', + backgroundColor: 'rgb(59, 66, 82)', + }, + root: { + backgroundColor: '#2E3440', + }, + }, + RaList: { + content: { + backgroundColor: '#3B4252', + borderRadius: '0px', + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: '#2E3440', + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: '#2E3440', + }, + drawerPaper: { + backgroundColor: '#2E3440 !important', + }, + }, + RaSearchInput: { + input: { + paddingLeft: '.9rem', + marginTop: '36px', + border: 0, + }, + }, + RaDatagrid: { + headerCell: { + '&:first-child': { + borderTopLeftRadius: '0px !important', + }, + '&:last-child': { + borderTopRightRadius: '0px !important', + }, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaLink: { + link: { + color: '#81A1C1', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: '2px solid rgba(255,255,255,0.25)', + }, + button: { + backgroundColor: '#4C566A', + minWidth: 48, + margin: '0 4px', + '@global': { + '> .MuiButton-label': { + padding: 0, + }, + }, + }, + actions: { + '@global': { + '.next-page': { + marginLeft: 8, + marginRight: 8, + }, + '.previous-page': { + marginRight: 8, + }, + }, + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/nuclear.css.js b/ui/src/themes/nuclear.css.js new file mode 100644 index 0000000..c445770 --- /dev/null +++ b/ui/src/themes/nuclear.css.js @@ -0,0 +1,63 @@ +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 new file mode 100644 index 0000000..b34896c --- /dev/null +++ b/ui/src/themes/nuclear.js @@ -0,0 +1,205 @@ +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', + }, + }, + }, + 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/spotify.js b/ui/src/themes/spotify.js new file mode 100644 index 0000000..725831c --- /dev/null +++ b/ui/src/themes/spotify.js @@ -0,0 +1,433 @@ +const spotifyGreen = { + 300: '#62ec83', + 500: '#1db954', + 900: '#008827', +} + +// For Album, Playlist +const musicListActions = { + padding: '1rem 0', + alignItems: 'center', + '@global': { + button: { + border: '1px solid transparent', + backgroundColor: 'inherit', + color: '#b3b3b3', + '&:hover': { + border: '1px solid #b3b3b3', + backgroundColor: 'inherit !important', + }, + }, + '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', + background: spotifyGreen['500'], + color: '#fff', + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${spotifyGreen['500']} !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: '#b3b3b3', + }, + }, +} + +export default { + themeName: 'Spotify-ish', + typography: { + fontFamily: "system-ui, 'Helvetica Neue', Helvetica, Arial", + h6: { + fontSize: '1rem', // AppBar title + }, + }, + palette: { + primary: { + light: spotifyGreen['300'], + main: spotifyGreen['500'], + }, + secondary: { + main: '#fff', + contrastText: '#fff', + }, + background: { + default: '#121212', + paper: '#121212', + }, + type: 'dark', + }, + overrides: { + MuiFormGroup: { + root: { + color: spotifyGreen['500'], + }, + }, + MuiMenuItem: { + root: { + fontSize: '0.875rem', + }, + }, + MuiDivider: { + root: { + margin: '.75rem 0', + }, + }, + MuiButton: { + root: { + background: spotifyGreen['500'], + color: '#fff', + border: '1px solid transparent', + borderRadius: 500, + '&:hover': { + background: `${spotifyGreen['900']} !important`, + }, + }, + textSecondary: { + border: '1px solid #b3b3b3', + background: '#000', + '&:hover': { + border: '1px solid #fff !important', + background: '#000 !important', + }, + }, + label: { + color: '#fff', + paddingRight: '1rem', + paddingLeft: '0.7rem', + }, + }, + MuiDrawer: { + root: { + background: '#000', + paddingTop: '10px', + }, + }, + MuiTableRow: { + root: { + padding: '10px 0', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: '#1d1d1d !important', + }, + '@global': { + 'td:nth-child(4)': { + color: '#fff !important', + }, + }, + }, + }, + MuiTableCell: { + root: { + borderBottom: '1px solid #1d1d1d', + padding: '10px !important', + color: '#b3b3b3 !important', + }, + head: { + borderBottom: '1px solid #282828', + fontSize: '0.75rem', + textTransform: 'uppercase', + letterSpacing: 1.2, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: '#000 !important', + boxShadow: 'none', + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: '#fff', + }, + albumSubtitle: { + color: '#b3b3b3', + }, + albumContainer: { + backgroundColor: '#181818', + borderRadius: '.5rem', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: '#282828', + }, + }, + albumPlayButton: { + backgroundColor: spotifyGreen['500'], + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${spotifyGreen['500']} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: 'linear-gradient(#1d1d1d, transparent)', + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontSize: 'calc(1.5rem + 1.5vw);', + fontWeight: 700, + color: '#fff', + }, + details: { + fontSize: '.875rem', + color: 'rgba(255,255,255, 0.8)', + }, + }, + NDAlbumDetails: { + root: { + background: 'linear-gradient(#1d1d1d, transparent)', + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontSize: 'calc(1rem + 1.5vw);', + fontWeight: 700, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + }, + recordMeta: { + fontSize: '.875rem', + color: 'rgba(255,255,255, 0.8)', + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: 'rgba(255,255,255, 0.8)', + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + 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', + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: '#b3b3b3', + }, + player: { + border: '10px solid blue', + }, + }, + NDLogin: { + main: { + boxShadow: 'inset 0 0 0 2000px rgba(0, 0, 0, .75)', + }, + systemNameLink: { + color: '#fff', + }, + card: { + border: '1px solid #282828', + }, + avatar: { + marginBottom: 0, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: 'linear-gradient(#171717, #121212)', + }, + }, + RaList: { + content: { + backgroundColor: 'inherit', + }, + }, + RaListToolbar: { + toolbar: { + padding: '0 .55rem !important', + }, + }, + RaSearchInput: { + input: { + paddingLeft: '.9rem', + border: 0, + '& .MuiInputBase-root': { + backgroundColor: 'white !important', + borderRadius: '20px !important', + color: 'black', + border: '0px', + '& fieldset': { + borderColor: 'white', + }, + '&:hover fieldset': { + borderColor: 'white', + }, + '&.Mui-focused fieldset': { + borderColor: 'white', + }, + '& svg': { + color: 'black !important', + }, + + '& .MuiOutlinedInput-input:-webkit-autofill': { + borderRadius: '20px 0px 0px 20px', + '-webkit-box-shadow': '0 0 0 100px #c2c1c2 inset', + '-webkit-text-fill-color': 'black', + }, + }, + }, + }, + RaFilter: { + form: { + '& .MuiOutlinedInput-input:-webkit-autofill': { + '-webkit-box-shadow': '0 0 0 100px #28282b inset', + '-webkit-text-fill-color': 'white', + }, + }, + }, + RaFilterButton: { + root: { + marginRight: '1rem', + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: '1px solid #b3b3b3', + }, + button: { + backgroundColor: 'inherit', + minWidth: 48, + margin: '0 4px', + border: '1px solid #282828', + '@global': { + '> .MuiButton-label': { + padding: 0, + }, + }, + }, + actions: { + '@global': { + '.next-page': { + marginLeft: 8, + marginRight: 8, + }, + '.previous-page': { + marginRight: 8, + }, + }, + }, + }, + RaSidebar: { + root: { + height: 'initial', + }, + }, + }, + player: { + theme: 'dark', + }, +} diff --git a/ui/src/themes/theme.test.js b/ui/src/themes/theme.test.js new file mode 100644 index 0000000..b65c3a5 --- /dev/null +++ b/ui/src/themes/theme.test.js @@ -0,0 +1,14 @@ +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() + }, + ) +}) diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js new file mode 100644 index 0000000..0d98603 --- /dev/null +++ b/ui/src/themes/useCurrentTheme.js @@ -0,0 +1,56 @@ +import { useSelector } from 'react-redux' +import useMediaQuery from '@material-ui/core/useMediaQuery' +import themes from './index' +import { AUTO_THEME_ID } from '../consts' +import config from '../config' +import { useEffect } from 'react' + +const useCurrentTheme = () => { + const prefersLightMode = useMediaQuery('(prefers-color-scheme: light)') + const theme = useSelector((state) => { + if (state.theme === AUTO_THEME_ID) { + return prefersLightMode ? themes.LightTheme : themes.DarkTheme + } + const themeName = + Object.keys(themes).find((t) => t === state.theme) || + Object.keys(themes).find( + (t) => themes[t].themeName === config.defaultTheme, + ) || + 'DarkTheme' + return themes[themeName] + }) + + useEffect(() => { + const styles = document.getElementsByTagName('style') + let style + for (let i = 0; i < styles.length; i++) { + if (styles[i].id === 'nd-player-style-override') { + style = styles[i] + } + } + if (theme.player.stylesheet) { + if (style === undefined) { + style = document.createElement('style') + style.id = 'nd-player-style-override' + style.innerHTML = theme.player.stylesheet + document.head.appendChild(style) + } else { + style.innerHTML = theme.player.stylesheet + } + } else { + if (style !== undefined) { + 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]) + + return theme +} + +export default useCurrentTheme diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx new file mode 100644 index 0000000..65c3be8 --- /dev/null +++ b/ui/src/themes/useCurrentTheme.test.jsx @@ -0,0 +1,164 @@ +import React from 'react' +import { Provider } from 'react-redux' +import { createStore } from 'redux' +import mediaQuery from 'css-mediaquery' +import { renderHook } from '@testing-library/react-hooks' +import useCurrentTheme from './useCurrentTheme' +import { themeReducer } from '../reducers/themeReducer' +import { AUTO_THEME_ID } from '../consts' + +function createMatchMedia(theme) { + return (query) => ({ + matches: mediaQuery.match(query, { 'prefers-color-scheme': theme }), + addListener: () => {}, + removeListener: () => {}, + }) +} + +beforeEach(() => { + document.body.style.backgroundColor = '' +}) + +describe('useCurrentTheme', () => { + describe('with user preference theme as light', () => { + beforeAll(() => { + window.matchMedia = createMatchMedia('light') + }) + it('sets theme as light in auto mode', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider store={createStore(themeReducer, { theme: AUTO_THEME_ID })}> + {children} + </Provider> + ), + }) + expect(result.current.themeName).toMatch('Light') + }) + it('sets theme as dark', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider store={createStore(themeReducer, { theme: 'DarkTheme' })}> + {children} + </Provider> + ), + }) + + expect(result.current.themeName).toMatch('Dark') + }) + it('sets theme as light', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider store={createStore(themeReducer, { theme: 'LightTheme' })}> + {children} + </Provider> + ), + }) + + expect(result.current.themeName).toMatch('Light') + }) + it('sets theme as spotify-ish', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider + store={createStore(themeReducer, { theme: 'SpotifyTheme' })} + > + {children} + </Provider> + ), + }) + + expect(result.current.themeName).toMatch('Spotify-ish') + }) + }) + describe('with user preference theme as dark', () => { + beforeAll(() => { + window.matchMedia = createMatchMedia('dark') + }) + it('sets theme as dark in auto mode', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider store={createStore(themeReducer, { theme: AUTO_THEME_ID })}> + {children} + </Provider> + ), + }) + + expect(result.current.themeName).toMatch('Dark') + }) + it('sets theme as dark', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider store={createStore(themeReducer, { theme: 'DarkTheme' })}> + {children} + </Provider> + ), + }) + + expect(result.current.themeName).toMatch('Dark') + }) + it('sets theme as light', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider store={createStore(themeReducer, { theme: 'LightTheme' })}> + {children} + </Provider> + ), + }) + + expect(result.current.themeName).toMatch('Light') + }) + it('sets theme as spotify-ish', () => { + const { result } = renderHook(() => useCurrentTheme(), { + wrapper: ({ children }) => ( + <Provider + store={createStore(themeReducer, { theme: 'SpotifyTheme' })} + > + {children} + </Provider> + ), + }) + + 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)') + }) + }) +}) diff --git a/ui/src/transcoding/TranscodingCreate.jsx b/ui/src/transcoding/TranscodingCreate.jsx new file mode 100644 index 0000000..aaf1226 --- /dev/null +++ b/ui/src/transcoding/TranscodingCreate.jsx @@ -0,0 +1,51 @@ +import React from 'react' +import { + TextInput, + SelectInput, + Create, + required, + SimpleForm, + useTranslate, +} from 'react-admin' +import { Title } from '../common' +import { BITRATE_CHOICES } from '../consts' + +const TranscodingTitle = () => { + const translate = useTranslate() + const resourceName = translate('resources.transcoding.name', { + smart_count: 1, + }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + return <Title subTitle={title} /> +} + +const TranscodingCreate = (props) => ( + <Create title={<TranscodingTitle />} {...props}> + <SimpleForm redirect="list" variant={'outlined'}> + <TextInput source="name" validate={[required()]} /> + <TextInput source="targetFormat" validate={[required()]} /> + <SelectInput + source="defaultBitRate" + choices={BITRATE_CHOICES} + defaultValue={192} + /> + <TextInput + source="command" + fullWidth + validate={[required()]} + helperText={ + <span> + Substitutions: <br /> + %s: File path <br /> + %b: BitRate (in kbps) + <br /> + </span> + } + /> + </SimpleForm> + </Create> +) + +export default TranscodingCreate diff --git a/ui/src/transcoding/TranscodingEdit.jsx b/ui/src/transcoding/TranscodingEdit.jsx new file mode 100644 index 0000000..5aba9a4 --- /dev/null +++ b/ui/src/transcoding/TranscodingEdit.jsx @@ -0,0 +1,39 @@ +import React from 'react' +import { + Edit, + required, + SelectInput, + SimpleForm, + TextInput, + useTranslate, +} from 'react-admin' +import { Title } from '../common' +import { TranscodingNote } from './TranscodingNote' +import { BITRATE_CHOICES } from '../consts' + +const TranscodingTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.transcoding.name', { + smart_count: 1, + }) + return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> +} + +const TranscodingEdit = (props) => { + return ( + <> + <TranscodingNote message={'message.transcodingEnabled'} /> + + <Edit title={<TranscodingTitle />} {...props}> + <SimpleForm variant={'outlined'}> + <TextInput source="name" validate={[required()]} /> + <TextInput source="targetFormat" validate={[required()]} /> + <SelectInput source="defaultBitRate" choices={BITRATE_CHOICES} /> + <TextInput source="command" fullWidth validate={[required()]} /> + </SimpleForm> + </Edit> + </> + ) +} + +export default TranscodingEdit diff --git a/ui/src/transcoding/TranscodingList.jsx b/ui/src/transcoding/TranscodingList.jsx new file mode 100644 index 0000000..bca8b49 --- /dev/null +++ b/ui/src/transcoding/TranscodingList.jsx @@ -0,0 +1,33 @@ +import React from 'react' +import { Datagrid, TextField } from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { SimpleList, List } from '../common' +import config from '../config' + +const TranscodingList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + return ( + <List + {...props} + exporter={false} + bulkActionButtons={config.enableTranscodingConfig} + > + {isXsmall ? ( + <SimpleList + primaryText={(r) => r.name} + secondaryText={(r) => `format: ${r.targetFormat}`} + tertiaryText={(r) => r.defaultBitRate} + /> + ) : ( + <Datagrid rowClick={config.enableTranscodingConfig ? 'edit' : 'show'}> + <TextField source="name" /> + <TextField source="targetFormat" /> + <TextField source="defaultBitRate" /> + <TextField source="command" /> + </Datagrid> + )} + </List> + ) +} + +export default TranscodingList diff --git a/ui/src/transcoding/TranscodingNote.jsx b/ui/src/transcoding/TranscodingNote.jsx new file mode 100644 index 0000000..a70776e --- /dev/null +++ b/ui/src/transcoding/TranscodingNote.jsx @@ -0,0 +1,33 @@ +import React from 'react' +import { Card, CardContent, Typography, Box } from '@material-ui/core' +import { useTranslate } from 'react-admin' + +export const Interpolate = ({ message, field, children }) => { + const split = message.split(`%{${field}}`) + return ( + <span> + {split[0]} + {children} + {split[1]} + </span> + ) +} +export const TranscodingNote = ({ message }) => { + const translate = useTranslate() + return ( + <Card> + <CardContent> + <Typography> + <Box fontWeight="fontWeightBold" component={'span'}> + {translate('message.note')}: + </Box>{' '} + <Interpolate message={translate(message)} field={'config'}> + <Box fontFamily="Monospace" component={'span'}> + ND_ENABLETRANSCODINGCONFIG=true + </Box> + </Interpolate> + </Typography> + </CardContent> + </Card> + ) +} diff --git a/ui/src/transcoding/TranscodingShow.jsx b/ui/src/transcoding/TranscodingShow.jsx new file mode 100644 index 0000000..e132afe --- /dev/null +++ b/ui/src/transcoding/TranscodingShow.jsx @@ -0,0 +1,27 @@ +import React from 'react' +import { Show, SimpleShowLayout, TextField } from 'react-admin' +import { Title } from '../common' +import { TranscodingNote } from './TranscodingNote' + +const TranscodingTitle = ({ record }) => { + return <Title subTitle={`Transcoding ${record ? record.name : ''}`} /> +} + +const TranscodingShow = (props) => { + return ( + <> + <TranscodingNote message={'message.transcodingDisabled'} /> + + <Show title={<TranscodingTitle />} {...props}> + <SimpleShowLayout> + <TextField source="name" /> + <TextField source="targetFormat" /> + <TextField source="defaultBitRate" /> + <TextField source="command" /> + </SimpleShowLayout> + </Show> + </> + ) +} + +export default TranscodingShow diff --git a/ui/src/transcoding/index.js b/ui/src/transcoding/index.js new file mode 100644 index 0000000..0bff293 --- /dev/null +++ b/ui/src/transcoding/index.js @@ -0,0 +1,14 @@ +import { MdTransform } from 'react-icons/md' +import TranscodingList from './TranscodingList' +import TranscodingEdit from './TranscodingEdit' +import TranscodingCreate from './TranscodingCreate' +import TranscodingShow from './TranscodingShow' +import config from '../config' + +export default { + list: TranscodingList, + edit: config.enableTranscodingConfig && TranscodingEdit, + create: config.enableTranscodingConfig && TranscodingCreate, + show: !config.enableTranscodingConfig && TranscodingShow, + icon: MdTransform, +} diff --git a/ui/src/useChangeThemeColor.jsx b/ui/src/useChangeThemeColor.jsx new file mode 100644 index 0000000..0a28359 --- /dev/null +++ b/ui/src/useChangeThemeColor.jsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react' +import useCurrentTheme from './themes/useCurrentTheme' + +const useChangeThemeColor = () => { + const theme = useCurrentTheme() + const color = + theme.palette?.primary?.light || theme.palette?.primary?.main || '#ffffff' + useEffect(() => { + const themeColor = document.querySelector("meta[name='theme-color']") + themeColor.setAttribute('content', color) + }, [color]) +} + +export default useChangeThemeColor diff --git a/ui/src/user/DeleteUserButton.jsx b/ui/src/user/DeleteUserButton.jsx new file mode 100644 index 0000000..8d78e6b --- /dev/null +++ b/ui/src/user/DeleteUserButton.jsx @@ -0,0 +1,76 @@ +import React from 'react' +import DeleteIcon from '@material-ui/icons/Delete' +import { makeStyles, alpha } from '@material-ui/core/styles' +import clsx from 'clsx' +import { + useDeleteWithConfirmController, + Button, + Confirm, + useNotify, + 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 DeleteUserButton = (props) => { + const { resource, record, basePath, className, onClick, ...rest } = props + + const notify = useNotify() + const redirect = useRedirect() + + const onSuccess = () => { + notify('resources.user.notifications.deleted') + redirect('/user') + } + + const { open, loading, handleDialogOpen, handleDialogClose, handleDelete } = + useDeleteWithConfirmController({ + resource, + record, + basePath, + onClick, + onSuccess, + }) + + const classes = useStyles(props) + return ( + <> + <Button + onClick={handleDialogOpen} + label="ra.action.delete" + className={clsx('ra-delete-button', classes.deleteButton, className)} + key="button" + {...rest} + > + <DeleteIcon /> + </Button> + <Confirm + isOpen={open} + loading={loading} + title="message.delete_user_title" + content="message.delete_user_content" + translateOptions={{ + name: record.name, + }} + onConfirm={handleDelete} + onClose={handleDialogClose} + /> + </> + ) +} + +export default DeleteUserButton diff --git a/ui/src/user/LibrarySelectionField.jsx b/ui/src/user/LibrarySelectionField.jsx new file mode 100644 index 0000000..4967720 --- /dev/null +++ b/ui/src/user/LibrarySelectionField.jsx @@ -0,0 +1,55 @@ +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 new file mode 100644 index 0000000..9777bab --- /dev/null +++ b/ui/src/user/LibrarySelectionField.test.jsx @@ -0,0 +1,168 @@ +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.jsx new file mode 100644 index 0000000..ce69b65 --- /dev/null +++ b/ui/src/user/UserCreate.jsx @@ -0,0 +1,102 @@ +import React, { useCallback } from 'react' +import { + BooleanInput, + Create, + email, + FormDataConsumer, + PasswordInput, + required, + SimpleForm, + TextInput, + 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() + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + const resourceName = translate('resources.user.name', { smart_count: 1 }) + const title = translate('ra.page.create', { + name: `${resourceName}`, + }) + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'create', + resource: 'user', + payload: { data: values }, + }, + { returnPromise: true }, + ) + notify('resources.user.notifications.created', 'info', { + smart_count: 1, + }) + redirect('/user') + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [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'}> + <TextInput + spellCheck={false} + source="userName" + validate={[required()]} + /> + <TextInput source="name" validate={[required()]} /> + <TextInput spellCheck={false} source="email" validate={[email()]} /> + <PasswordInput + spellCheck={false} + source="password" + 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> + ) +} + +export default UserCreate diff --git a/ui/src/user/UserEdit.jsx b/ui/src/user/UserEdit.jsx new file mode 100644 index 0000000..2283dd8 --- /dev/null +++ b/ui/src/user/UserEdit.jsx @@ -0,0 +1,183 @@ +import React, { useCallback } from 'react' +import { makeStyles } from '@material-ui/core/styles' +import { + TextInput, + BooleanInput, + DateField, + PasswordInput, + Edit, + required, + email, + SimpleForm, + useTranslate, + Toolbar, + SaveButton, + useMutation, + useNotify, + useRedirect, + useRefresh, + FormDataConsumer, + usePermissions, + useRecordContext, +} from 'react-admin' +import { Typography } from '@material-ui/core' +import { Title } from '../common' +import DeleteUserButton from './DeleteUserButton' +import { LibrarySelectionField } from './LibrarySelectionField.jsx' +import { validateUserForm } from './userValidation' + +const useStyles = makeStyles({ + toolbar: { + display: 'flex', + justifyContent: 'space-between', + }, +}) + +const UserTitle = ({ record }) => { + const translate = useTranslate() + const resourceName = translate('resources.user.name', { smart_count: 1 }) + return <Title subTitle={`${resourceName} ${record ? record.name : ''}`} /> +} + +const UserToolbar = ({ showDelete, ...props }) => ( + <Toolbar {...props} classes={useStyles()}> + <SaveButton disabled={props.pristine} /> + {showDelete && <DeleteUserButton />} + </Toolbar> +) + +const CurrentPasswordInput = ({ formData, isMyself, ...rest }) => { + const { permissions } = usePermissions() + return formData.changePassword && (isMyself || permissions !== 'admin') ? ( + <PasswordInput className="ra-input" source="currentPassword" {...rest} /> + ) : null +} + +const NewPasswordInput = ({ formData, ...rest }) => { + const translate = useTranslate() + return formData.changePassword ? ( + <PasswordInput + source="password" + className="ra-input" + label={translate('resources.user.fields.newPassword')} + {...rest} + /> + ) : null +} + +const UserEdit = (props) => { + const { permissions } = props + const translate = useTranslate() + const [mutate] = useMutation() + const notify = useNotify() + const redirect = useRedirect() + const refresh = useRefresh() + + const isMyself = props.id === localStorage.getItem('userId') + const getNameHelperText = () => + isMyself && { + helperText: translate('resources.user.helperTexts.name'), + } + const canDelete = permissions === 'admin' && !isMyself + + const save = useCallback( + async (values) => { + try { + await mutate( + { + type: 'update', + resource: 'user', + payload: { id: values.id, data: values }, + }, + { returnPromise: true }, + ) + notify('resources.user.notifications.updated', 'info', { + smart_count: 1, + }) + permissions === 'admin' ? redirect('/user') : refresh() + } catch (error) { + if (error.body.errors) { + return error.body.errors + } + } + }, + [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 + spellCheck={false} + source="userName" + validate={[required()]} + /> + )} + <TextInput + source="name" + validate={[required()]} + {...getNameHelperText()} + /> + <TextInput spellCheck={false} source="email" validate={[email()]} /> + <BooleanInput source="changePassword" /> + <FormDataConsumer> + {(formDataProps) => ( + <CurrentPasswordInput + spellCheck={false} + isMyself={isMyself} + {...formDataProps} + /> + )} + </FormDataConsumer> + <FormDataConsumer> + {(formDataProps) => ( + <NewPasswordInput spellCheck={false} {...formDataProps} /> + )} + </FormDataConsumer> + + {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> + )} + + <DateField variant="body1" source="lastLoginAt" showTime /> + <DateField variant="body1" source="lastAccessAt" showTime /> + <DateField variant="body1" source="updatedAt" showTime /> + <DateField variant="body1" source="createdAt" showTime /> + </SimpleForm> + </Edit> + ) +} + +export default UserEdit diff --git a/ui/src/user/UserEdit.test.jsx b/ui/src/user/UserEdit.test.jsx new file mode 100644 index 0000000..75a9a1a --- /dev/null +++ b/ui/src/user/UserEdit.test.jsx @@ -0,0 +1,130 @@ +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, +} + +// Mock React-Admin completely with simpler implementations +vi.mock('react-admin', () => ({ + Edit: ({ children, title }) => ( + <div data-testid="edit-component"> + {title} + {children} + </div> + ), + SimpleForm: ({ children }) => ( + <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}`} /> + ), + DateField: ({ source }) => ( + <div data-testid={`date-field-${source}`}>Date</div> + ), + 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: () => [vi.fn()], + useNotify: () => vi.fn(), + useRedirect: () => vi.fn(), + useRefresh: () => vi.fn(), + 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>, +})) + +// 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 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() + }) +}) diff --git a/ui/src/user/UserList.jsx b/ui/src/user/UserList.jsx new file mode 100644 index 0000000..4faf317 --- /dev/null +++ b/ui/src/user/UserList.jsx @@ -0,0 +1,52 @@ +import React from 'react' +import { + BooleanField, + Datagrid, + Filter, + SearchInput, + SimpleList, + TextField, +} from 'react-admin' +import { useMediaQuery } from '@material-ui/core' +import { List, DateField } from '../common' + +const UserFilter = (props) => ( + <Filter {...props} variant={'outlined'}> + <SearchInput id="search" source="name" alwaysOn /> + </Filter> +) + +const UserList = (props) => { + const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) + + return ( + <List + {...props} + sort={{ field: 'userName', order: 'ASC' }} + exporter={false} + bulkActionButtons={false} + filters={<UserFilter />} + > + {isXsmall ? ( + <SimpleList + primaryText={(record) => record.userName} + secondaryText={(record) => + record.lastLoginAt && new Date(record.lastLoginAt).toLocaleString() + } + tertiaryText={(record) => (record.isAdmin ? '[admin]️' : '')} + /> + ) : ( + <Datagrid rowClick="edit"> + <TextField source="userName" /> + <TextField source="name" /> + <BooleanField source="isAdmin" /> + <DateField source="lastLoginAt" sortByOrder={'DESC'} /> + <DateField source="lastAccessAt" sortByOrder={'DESC'} /> + <DateField source="updatedAt" sortByOrder={'DESC'} /> + </Datagrid> + )} + </List> + ) +} + +export default UserList diff --git a/ui/src/user/index.js b/ui/src/user/index.js new file mode 100644 index 0000000..946a525 --- /dev/null +++ b/ui/src/user/index.js @@ -0,0 +1,9 @@ +import UserList from './UserList' +import UserEdit from './UserEdit' +import UserCreate from './UserCreate' + +export default { + list: UserList, + edit: UserEdit, + create: UserCreate, +} diff --git a/ui/src/user/userValidation.js b/ui/src/user/userValidation.js new file mode 100644 index 0000000..e90fd2a --- /dev/null +++ b/ui/src/user/userValidation.js @@ -0,0 +1,19 @@ +// 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', + ) + } + } + + return errors +} diff --git a/ui/src/user/userValidation.test.js b/ui/src/user/userValidation.test.js new file mode 100644 index 0000000..2ee4739 --- /dev/null +++ b/ui/src/user/userValidation.test.js @@ -0,0 +1,70 @@ +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', + ) + }) + }) +}) diff --git a/ui/src/utils/calculateReplayGain.js b/ui/src/utils/calculateReplayGain.js new file mode 100644 index 0000000..0ce69fa --- /dev/null +++ b/ui/src/utils/calculateReplayGain.js @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..cfcb84b --- /dev/null +++ b/ui/src/utils/formatters.js @@ -0,0 +1,101 @@ +export const formatBytes = (bytes, decimals = 2) => { + if (bytes === 0) return '0 Bytes' + + const k = 1024 + const dm = decimals < 0 ? 0 : decimals + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] + + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i] +} + +export const formatDuration = (d) => { + d = Math.round(d) + const days = Math.floor(d / 86400) + const hours = Math.floor(d / 3600) % 24 + const minutes = Math.floor(d / 60) % 60 + const seconds = Math.floor(d % 60) + const f = [hours, minutes, seconds] + .map((v) => v.toString()) + .map((v) => (v.length !== 2 ? '0' + v : v)) + .filter((v, i) => v !== '00' || i > 0 || days > 0) + .join(':') + + 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) => { + const dashes = date.split('-').length - 1 + let options = { + year: 'numeric', + timeZone: 'UTC', + ...(dashes > 0 && { month: 'short' }), + ...(dashes > 1 && { day: 'numeric' }), + } + if (dashes > 2 || (dashes === 0 && date.length > 4)) { + return '' + } + return new Date(date).toLocaleDateString(locale, options) +} + +export const formatNumber = (value, locale) => { + if (value === null || value === undefined) return '0' + return value.toLocaleString(locale) +} diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js new file mode 100644 index 0000000..d633e96 --- /dev/null +++ b/ui/src/utils/formatters.test.js @@ -0,0 +1,155 @@ +import { + formatBytes, + formatDuration, + formatDuration2, + formatFullDate, + formatNumber, + formatShortDuration, +} from './formatters' + +describe('formatBytes', () => { + it('format bytes', () => { + expect(formatBytes(0)).toEqual('0 Bytes') + expect(formatBytes(1000)).toEqual('1000 Bytes') + expect(formatBytes(1024)).toEqual('1 KB') + expect(formatBytes(1024 * 1024)).toEqual('1 MB') + expect(formatBytes(1024 * 1024 * 1024)).toEqual('1 GB') + expect(formatBytes(1024 * 1024 * 1024 * 1024)).toEqual('1 TB') + }) +}) + +const day = 86400 +const hour = 3600 +const minute = 60 + +describe('formatDuration', () => { + it('formats seconds', () => { + expect(formatDuration(0)).toEqual('00:00') + expect(formatDuration(59)).toEqual('00:59') + expect(formatDuration(59.99)).toEqual('01:00') + }) + + 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', + ) + 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') + expect(formatFullDate('199704')).toEqual('') + }) +}) diff --git a/ui/src/utils/index.js b/ui/src/utils/index.js new file mode 100644 index 0000000..779b6f8 --- /dev/null +++ b/ui/src/utils/index.js @@ -0,0 +1,5 @@ +export * from './formatters' +export * from './intersperse' +export * from './notifications' +export * from './openInNewTab' +export * from './urls' diff --git a/ui/src/utils/intersperse.js b/ui/src/utils/intersperse.js new file mode 100644 index 0000000..ce48f5e --- /dev/null +++ b/ui/src/utils/intersperse.js @@ -0,0 +1,20 @@ +/* intersperse: Return an array with the separator interspersed between + * each element of the input array. + * + * > _([1,2,3]).intersperse(0) + * [1,0,2,0,3] + * + * From: https://stackoverflow.com/a/23619085 + */ +export const intersperse = (arr, sep) => { + if (arr.length === 0) { + return [] + } + + return arr.slice(1).reduce( + function (xs, x, i) { + return xs.concat([sep, x]) + }, + [arr[0]], + ) +} diff --git a/ui/src/utils/notifications.js b/ui/src/utils/notifications.js new file mode 100644 index 0000000..49d6eea --- /dev/null +++ b/ui/src/utils/notifications.js @@ -0,0 +1,12 @@ +export const sendNotification = (title, body = '', image = '') => { + checkForNotificationPermission() + new Notification(title, { + body: body, + icon: image, + silent: true, + }) +} + +const checkForNotificationPermission = () => { + return 'Notification' in window && Notification.permission === 'granted' +} diff --git a/ui/src/utils/openInNewTab.js b/ui/src/utils/openInNewTab.js new file mode 100644 index 0000000..ea34a06 --- /dev/null +++ b/ui/src/utils/openInNewTab.js @@ -0,0 +1,5 @@ +export const openInNewTab = (url) => { + const win = window.open(url, '_blank') + win.focus() + return win +} diff --git a/ui/src/utils/removeHomeCache.js b/ui/src/utils/removeHomeCache.js new file mode 100644 index 0000000..08ed720 --- /dev/null +++ b/ui/src/utils/removeHomeCache.js @@ -0,0 +1,20 @@ +export const removeHomeCache = async () => { + try { + const workboxKey = (await caches.keys()).find((key) => + key.startsWith('workbox-precache'), + ) + if (!workboxKey) return + + const workboxCache = await caches.open(workboxKey) + const indexKey = (await workboxCache.keys()).find((key) => + key.url.includes('app/index.html'), + ) + + if (indexKey) { + await workboxCache.delete(indexKey) + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('error reading cache', e) + } +} diff --git a/ui/src/utils/urls.js b/ui/src/utils/urls.js new file mode 100644 index 0000000..5788096 --- /dev/null +++ b/ui/src/utils/urls.js @@ -0,0 +1,46 @@ +import config from '../config' + +export const baseUrl = (path) => { + const base = config.baseURL || '' + const parts = [base] + parts.push(path.replace(/^\//, '')) + 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) => { + const url = new URL( + shareUrl(config.publicBaseUrl + '/' + id), + window.location.href, + ) + return url.href +} + +export const shareStreamUrl = (id) => { + return shareUrl(config.publicBaseUrl + '/s/' + id) +} + +export const shareDownloadUrl = (id) => { + return shareUrl(config.publicBaseUrl + '/d/' + id) +} + +export const shareCoverUrl = (id, square) => { + return shareUrl( + config.publicBaseUrl + + '/img/' + + id + + '?size=300' + + (square ? '&square=true' : ''), + ) +} + +export const docsUrl = (path) => `https://www.navidrome.org${path}` diff --git a/ui/src/utils/validations.js b/ui/src/utils/validations.js new file mode 100644 index 0000000..792726e --- /dev/null +++ b/ui/src/utils/validations.js @@ -0,0 +1,25 @@ +export const urlValidate = (value) => { + if (!value) { + return undefined + } + + try { + new URL(value) + return undefined + } catch (_) { + 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 new file mode 100644 index 0000000..10f67d1 --- /dev/null +++ b/ui/src/utils/validations.test.js @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json new file mode 100644 index 0000000..d739292 --- /dev/null +++ b/ui/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "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 new file mode 100644 index 0000000..ea9d0cd --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/ui/tsconfig.node.json b/ui/tsconfig.node.json new file mode 100644 index 0000000..3afdd6e --- /dev/null +++ b/ui/tsconfig.node.json @@ -0,0 +1,13 @@ +{ + "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 new file mode 100644 index 0000000..dee9d39 --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,74 @@ +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', + devOptions: { + enabled: true, + }, + }), + ], + server: { + host: true, + port: frontendPort, + proxy: { + '^/(auth|api|rest|backgrounds)/.*': 'http://localhost:' + backendPort, + }, + }, + base: './', + 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/utils/cache/cache_suite_test.go b/utils/cache/cache_suite_test.go new file mode 100644 index 0000000..8c0d289 --- /dev/null +++ b/utils/cache/cache_suite_test.go @@ -0,0 +1,17 @@ +package cache + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCache(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Cache Suite") +} diff --git a/utils/cache/cached_http_client.go b/utils/cache/cached_http_client.go new file mode 100644 index 0000000..94d3310 --- /dev/null +++ b/utils/cache/cached_http_client.go @@ -0,0 +1,110 @@ +package cache + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "github.com/navidrome/navidrome/log" +) + +const cacheSizeLimit = 100 + +type HTTPClient struct { + cache SimpleCache[string, string] + hc httpDoer + ttl time.Duration +} + +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +type requestData struct { + Method string + Header http.Header + URL string + 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, + }) + return c +} + +func (c *HTTPClient) 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) + if err != nil { + return nil, err + } + return c.deserializeResponse(req, respStr) +} + +func (c *HTTPClient) serializeReq(req *http.Request) string { + data := requestData{ + Method: req.Method, + Header: req.Header, + URL: req.URL.String(), + } + if req.Body != nil { + bodyData, _ := io.ReadAll(req.Body) + bodyStr := base64.StdEncoding.EncodeToString(bodyData) + data.Body = &bodyStr + } + j, _ := json.Marshal(&data) + return string(j) +} + +func (c *HTTPClient) deserializeReq(reqStr string) (*http.Request, error) { + var data requestData + _ = json.Unmarshal([]byte(reqStr), &data) + var body io.Reader + if data.Body != nil { + bodyStr, _ := base64.StdEncoding.DecodeString(*data.Body) + body = strings.NewReader(string(bodyStr)) + } + req, err := http.NewRequest(data.Method, data.URL, body) + if err != nil { + return nil, err + } + req.Header = data.Header + return req, nil +} + +func (c *HTTPClient) 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) { + r := bufio.NewReader(strings.NewReader(respStr)) + return http.ReadResponse(r, req) +} diff --git a/utils/cache/cached_http_client_test.go b/utils/cache/cached_http_client_test.go new file mode 100644 index 0000000..1ec1a3a --- /dev/null +++ b/utils/cache/cached_http_client_test.go @@ -0,0 +1,93 @@ +package cache + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/consts" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("HTTPClient", func() { + Context("GET", func() { + var chc *HTTPClient + var ts *httptest.Server + var requestsReceived int + var header string + + BeforeEach(func() { + 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) + }) + + AfterEach(func() { + defer ts.Close() + }) + + It("caches repeated requests", func() { + r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil) + resp, err := chc.Do(r) + Expect(err).To(BeNil()) + body, err := io.ReadAll(resp.Body) + Expect(err).To(BeNil()) + Expect(string(body)).To(Equal("Hello, [doe]")) + Expect(requestsReceived).To(Equal(1)) + + // Same request + r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil) + resp, err = chc.Do(r) + Expect(err).To(BeNil()) + body, err = io.ReadAll(resp.Body) + Expect(err).To(BeNil()) + Expect(string(body)).To(Equal("Hello, [doe]")) + Expect(requestsReceived).To(Equal(1)) + + // Different request + r, _ = http.NewRequest("GET", ts.URL, nil) + resp, err = chc.Do(r) + Expect(err).To(BeNil()) + body, err = io.ReadAll(resp.Body) + Expect(err).To(BeNil()) + Expect(string(body)).To(Equal("Hello, []")) + Expect(requestsReceived).To(Equal(2)) + + // Different again (same as before, but with header) + r, _ = http.NewRequest("GET", ts.URL, nil) + r.Header.Add("head", "this is a header") + resp, err = chc.Do(r) + Expect(err).To(BeNil()) + body, err = io.ReadAll(resp.Body) + Expect(err).To(BeNil()) + Expect(string(body)).To(Equal("Hello, []")) + Expect(header).To(Equal("this is a header")) + Expect(requestsReceived).To(Equal(3)) + }) + + It("expires responses after TTL", func() { + requestsReceived = 0 + chc = NewHTTPClient(http.DefaultClient, 10*time.Millisecond) + + r, _ := http.NewRequest("GET", ts.URL+"?name=doe", nil) + _, err := chc.Do(r) + Expect(err).To(BeNil()) + Expect(requestsReceived).To(Equal(1)) + + // Wait more than the TTL + time.Sleep(50 * time.Millisecond) + + // Same request + r, _ = http.NewRequest("GET", ts.URL+"?name=doe", nil) + _, err = chc.Do(r) + Expect(err).To(BeNil()) + Expect(requestsReceived).To(Equal(2)) + }) + }) +}) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go new file mode 100644 index 0000000..5edc533 --- /dev/null +++ b/utils/cache/file_caches.go @@ -0,0 +1,283 @@ +package cache + +import ( + "context" + "fmt" + "io" + "path/filepath" + "sync" + "sync/atomic" + "time" + + "github.com/djherbis/fscache" + "github.com/dustin/go-humanize" + "github.com/hashicorp/go-multierror" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "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, + cacheSize: cacheSize, + cacheFolder: filepath.FromSlash(cacheFolder), + maxItems: maxItems, + getReader: getReader, + mutex: &sync.RWMutex{}, + } + + go func() { + start := time.Now() + cache, err := newFSCache(fc.name, fc.cacheSize, fc.cacheFolder, fc.maxItems) + fc.mutex.Lock() + defer fc.mutex.Unlock() + fc.cache = cache + 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) + if err != nil { + log.Error(fmt.Sprintf("Cache %s will be DISABLED due to previous errors", "name"), fc.name, err) + } + if fc.disabled { + log.Debug("Cache DISABLED", "cache", fc.name, "size", fc.cacheSize) + } + }() + + return fc +} + +type fileCache struct { + name string + cacheSize string + cacheFolder string + maxItems int + cache fscache.Cache + getReader ReadFunc + disabled bool + ready atomic.Bool + mutex *sync.RWMutex +} + +func (fc *fileCache) Available(_ context.Context) bool { + fc.mutex.RLock() + defer fc.mutex.RUnlock() + + 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) + return nil + } + if !fc.cache.Exists(key) { + return nil + } + err := fc.cache.Remove(key) + if err != nil { + log.Warn(ctx, "Error removing key from cache", "cache", fc.name, "key", key, err) + } + return err +} + +func (fc *fileCache) Get(ctx context.Context, arg Item) (*CachedStream, error) { + if !fc.Available(ctx) { + log.Debug(ctx, "Cache not initialized yet. Reading data directly from reader", "cache", fc.name) + reader, err := fc.getReader(ctx, arg) + if err != nil { + return nil, err + } + return &CachedStream{Reader: reader}, nil + } + + key := arg.Key() + r, w, err := fc.cache.Get(key) + if err != nil { + return nil, err + } + + cached := w == nil + + if !cached { + log.Trace(ctx, "Cache MISS", "cache", fc.name, "key", key) + reader, err := fc.getReader(ctx, arg) + if err != nil { + _ = r.Close() + _ = w.Close() + _ = fc.invalidate(ctx, key) + return nil, err + } + go func() { + 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 { + log.Trace(ctx, "File successfully stored in cache", "cache", fc.name, "key", key) + } + }() + } + + // If it is in the cache, check if the stream is done being written. If so, return a ReadSeeker + if cached { + size := getFinalCachedSize(r) + if size >= 0 { + log.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key, "size", size) + sr := io.NewSectionReader(r, 0, size) + return &CachedStream{ + Reader: sr, + Seeker: sr, + Closer: r, + Cached: true, + }, nil + } else { + log.Trace(ctx, "Cache HIT", "cache", fc.name, "key", key) + } + } + + // All other cases, just return the cache reader, without Seek capabilities + 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 + io.Closer + Cached bool +} + +func (s *CachedStream) Close() error { + if s.Closer != nil { + return s.Closer.Close() + } + if c, ok := s.Reader.(io.Closer); ok { + return c.Close() + } + return nil +} + +func getFinalCachedSize(r fscache.ReadAtCloser) int64 { + cr, ok := r.(*fscache.CacheReader) + if ok { + size, final, err := cr.Size() + if final && err == nil { + return size + } + } + return -1 +} + +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) + } + if c, ok := r.(io.Closer); ok { + if cErr := c.Close(); cErr != nil { + err = multierror.Append(err, fmt.Errorf("closing source stream: %w", cErr)) + } + } + + if cErr := w.Close(); cErr != nil { + err = multierror.Append(err, fmt.Errorf("closing cache writer: %w", cErr)) + } + return err +} + +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, + "defaultSize", humanize.Bytes(consts.DefaultCacheSize)) + size = consts.DefaultCacheSize + } + if size == 0 { + log.Warn(fmt.Sprintf("%s cache disabled", name)) + return nil, nil + } + + lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) + h := fscache.NewLRUHaunterStrategy(lru) + 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, err + } + + ck, err := fscache.NewCacheWithHaunter(fs, h) + if err != nil { + log.Error(fmt.Sprintf("Error initializing %s cache", name), err) + return nil, err + } + ck.SetKeyMapper(fs.KeyMapper) + + return ck, nil +} diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go new file mode 100644 index 0000000..72f4463 --- /dev/null +++ b/utils/cache/file_caches_test.go @@ -0,0 +1,150 @@ +package cache + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// 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() }).Should(BeTrue()) + return fc +} + +var _ = Describe("File Caches", func() { + BeforeEach(func() { + tmpDir, _ := os.MkdirTemp("", "file_caches") + DeferCleanup(func() { + configtest.SetupConfig() + _ = os.RemoveAll(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, "test")) + Expect(os.IsNotExist(err)).To(BeFalse()) + }) + + It("creates the cache folder with invalid size", func() { + fc := callNewFileCache("test", "abc", "test", 0, nil) + Expect(fc.cache).ToNot(BeNil()) + Expect(fc.disabled).To(BeFalse()) + }) + + It("returns empty if cache size is '0'", func() { + fc := callNewFileCache("test", "0", "test", 0, nil) + 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() { + It("caches data if cache is enabled", func() { + called := false + fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + called = true + return strings.NewReader(arg.Key()), nil + }) + // First call is a MISS + s, err := fc.Get(context.Background(), &testArg{"test"}) + Expect(err).To(BeNil()) + Expect(s.Cached).To(BeFalse()) + Expect(s.Closer).To(BeNil()) + Expect(io.ReadAll(s)).To(Equal([]byte("test"))) + + // Second call is a HIT + called = false + s, err = fc.Get(context.Background(), &testArg{"test"}) + Expect(err).To(BeNil()) + Expect(io.ReadAll(s)).To(Equal([]byte("test"))) + Expect(s.Cached).To(BeTrue()) + Expect(s.Closer).ToNot(BeNil()) + Expect(called).To(BeFalse()) + }) + + It("does not cache data if cache is disabled", func() { + called := false + fc := callNewFileCache("test", "0", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + called = true + return strings.NewReader(arg.Key()), nil + }) + // First call is a MISS + s, err := fc.Get(context.Background(), &testArg{"test"}) + Expect(err).To(BeNil()) + Expect(s.Cached).To(BeFalse()) + Expect(io.ReadAll(s)).To(Equal([]byte("test"))) + + // Second call is also a MISS + called = false + s, err = fc.Get(context.Background(), &testArg{"test"}) + Expect(err).To(BeNil()) + Expect(io.ReadAll(s)).To(Equal([]byte("test"))) + Expect(s.Cached).To(BeFalse()) + Expect(called).To(BeTrue()) + }) + + Context("reader errors", func() { + When("creating a reader fails", func() { + It("does not cache", func() { + fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return nil, errors.New("failed") + }) + + _, err := fc.Get(context.Background(), &testArg{"test"}) + Expect(err).To(MatchError("failed")) + }) + }) + When("reader returns error", func() { + It("does not cache", func() { + fc := callNewFileCache("test", "1KB", "test", 0, func(ctx context.Context, arg Item) (io.Reader, error) { + return errFakeReader{errors.New("read failure")}, nil + }) + + s, err := fc.Get(context.Background(), &testArg{"test"}) + Expect(err).ToNot(HaveOccurred()) + _, _ = io.Copy(io.Discard, s) + // TODO How to make the fscache reader return the underlying reader error? + //Expect(err).To(MatchError("read failure")) + + // Data should not be cached (or eventually be removed from cache) + Eventually(func() bool { + s, _ = fc.Get(context.Background(), &testArg{"test"}) + if s != nil { + return s.Cached + } + return false + }).Should(BeFalse()) + }) + }) + }) + }) +}) + +type testArg struct{ s string } + +func (t *testArg) Key() string { return t.s } + +type errFakeReader struct{ err error } + +func (e errFakeReader) Read([]byte) (int, error) { return 0, e.err } diff --git a/utils/cache/file_haunter.go b/utils/cache/file_haunter.go new file mode 100644 index 0000000..bee06ef --- /dev/null +++ b/utils/cache/file_haunter.go @@ -0,0 +1,130 @@ +package cache + +import ( + "sort" + "time" + + "github.com/djherbis/fscache" + "github.com/dustin/go-humanize" + "github.com/navidrome/navidrome/log" +) + +type haunterKV struct { + key string + value fscache.Entry + info fscache.FileInfo +} + +// NewFileHaunter returns a simple haunter which runs every "period" +// and scrubs older files when the total file size is over maxSize or +// total item count is over maxItems. It also removes empty (invalid) files. +// 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 { + return &fileHaunter{ + name: name, + period: period, + maxItems: maxItems, + maxSize: maxSize, + } +} + +type fileHaunter struct { + name string + period time.Duration + maxItems int + maxSize uint64 +} + +func (j *fileHaunter) Next() time.Duration { + return j.period +} + +func (j *fileHaunter) Scrub(c fscache.CacheAccessor) (keysToReap []string) { + var count int + var size uint64 + var okFiles []haunterKV + + log.Trace("Running cache cleanup", "cache", j.name, "maxSize", humanize.Bytes(j.maxSize)) + c.EnumerateEntries(func(key string, e fscache.Entry) bool { + if e.InUse() { + return true + } + + fileInfo, err := c.Stat(e.Name()) + if err != nil { + return true + } + + if fileInfo.Size() == 0 { + log.Trace("Removing invalid empty file", "file", e.Name()) + keysToReap = append(keysToReap, key) + } + + count++ + size = size + uint64(fileInfo.Size()) + okFiles = append(okFiles, haunterKV{ + key: key, + value: e, + info: fileInfo, + }) + + return true + }) + + sort.Slice(okFiles, func(i, j int) bool { + iLastRead := okFiles[i].info.AccessTime() + jLastRead := okFiles[j].info.AccessTime() + + return iLastRead.Before(jLastRead) + }) + + collectKeysToReapFn := func() bool { + var key *string + var err error + key, count, size, err = j.removeFirst(&okFiles, count, size) + if err != nil { + return false + } + if key != nil { + keysToReap = append(keysToReap, *key) + } + + return true + } + + log.Trace("Current cache stats", "cache", j.name, "size", humanize.Bytes(size), "numItems", count) + + if j.maxItems > 0 { + for count > j.maxItems { + if !collectKeysToReapFn() { + break + } + } + } + + if j.maxSize > 0 { + for size > j.maxSize { + if !collectKeysToReapFn() { + break + } + } + } + + if len(keysToReap) > 0 { + log.Trace("Removing items from cache", "cache", j.name, "numItems", len(keysToReap)) + } + return keysToReap +} + +func (j *fileHaunter) removeFirst(items *[]haunterKV, count int, size uint64) (*string, int, uint64, error) { + var f haunterKV + + f, *items = (*items)[0], (*items)[1:] + + count-- + size = size - uint64(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 new file mode 100644 index 0000000..bd1eb56 --- /dev/null +++ b/utils/cache/file_haunter_test.go @@ -0,0 +1,102 @@ +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) }) + + fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy( + cache.NewFileHaunter("", maxItems, maxSize, 300*time.Millisecond), + )) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(fsCache.Clean) + + Expect(createTestFiles(fsCache)).To(Succeed()) + + <-time.After(400 * time.Millisecond) + }) + + Context("When maxSize is defined", func() { + BeforeEach(func() { + maxSize = 20 + }) + + It("removes files", func() { + Expect(os.ReadDir(cacheDir)).To(HaveLen(4)) + Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + // TODO Fix flaky tests + //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") + }) + }) + + XContext("When maxItems is defined", func() { + BeforeEach(func() { + maxItems = 3 + }) + + It("removes files", func() { + Expect(os.ReadDir(cacheDir)).To(HaveLen(maxItems)) + Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + // TODO Fix flaky tests + //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") + //Expect(fsCache.Exists("stream-1")).To(BeFalse(), "stream-1 should have been scrubbed") + }) + }) +}) + +func createTestFiles(c *fscache.FSCache) error { + // 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) { + return errors.New(name + " should exist") + } + + <-time.After(10 * time.Millisecond) + + err := r.Close() + if err != nil { + return err + } + } + return 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/simple_cache.go b/utils/cache/simple_cache.go new file mode 100644 index 0000000..cac41be --- /dev/null +++ b/utils/cache/simple_cache.go @@ -0,0 +1,153 @@ +package cache + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync/atomic" + "time" + + "github.com/jellydator/ttlcache/v3" + . "github.com/navidrome/navidrome/utils/gg" +) + +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) + 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, + } + 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] +} + +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]) 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 +} + +func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) { + var err error + loaderWrapper := ttlcache.LoaderFunc[K, V]( + func(t *ttlcache.Cache[K, V], key K) *ttlcache.Item[K, V] { + c.evictExpired() + var value V + var ttl time.Duration + value, ttl, err = loader(key) + if err != nil { + return nil + } + return t.Set(key, value, ttl) + }, + ) + item := c.data.Get(key, ttlcache.WithLoader[K, V](loaderWrapper)) + if item == nil { + var zero V + if err != nil { + return zero, fmt.Errorf("cache error: loader returned %w", err) + } + return zero, errors.New("item not found") + } + return item.Value(), nil +} + +func (c *simpleCache[K, V]) evictExpired() { + if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { + c.data.DeleteExpired() + c.evictionDeadline.Store(P(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 new file mode 100644 index 0000000..45ba2c9 --- /dev/null +++ b/utils/cache/simple_cache_test.go @@ -0,0 +1,161 @@ +package cache + +import ( + "errors" + "fmt" + "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()) + }) + }) + + 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 new file mode 100644 index 0000000..281e2bf --- /dev/null +++ b/utils/cache/spread_fs.go @@ -0,0 +1,110 @@ +package cache + +import ( + "crypto/sha1" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/djherbis/atime" + "github.com/djherbis/fscache" + "github.com/djherbis/stream" + "github.com/navidrome/navidrome/log" +) + +type spreadFS struct { + root string + mode os.FileMode + init func() error +} + +// NewSpreadFS returns a FileSystem rooted at directory dir. This FS hashes the key and +// distributes all files in a layout like XX/XX/XXXXXXXXXX. Ex: +// +// Key is abc123.300x300.jpg +// Hash would be: c574aeb3caafcf93ee337f0cf34e31a428ba3f13 +// File in cache would be: c5 / 74 / c574aeb3caafcf93ee337f0cf34e31a428ba3f13 +// +// The idea is to avoid having too many files in one dir, which could potentially cause performance issues +// and may hit limitations depending on the OS. +// See discussion here: https://github.com/djherbis/fscache/issues/8#issuecomment-614319323 +// +// dir is created with specified mode if it doesn't exist. +func NewSpreadFS(dir string, mode os.FileMode) (*spreadFS, error) { + f := &spreadFS{root: dir, mode: mode, init: func() error { + return os.MkdirAll(dir, mode) + }} + return f, f.init() +} + +func (sfs *spreadFS) Reload(f func(key string, name string)) error { + count := 0 + 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) + } + path, err := filepath.Rel(sfs.root, absoluteFilePath) + if err != nil { + return nil //nolint:nilerr + } + + // 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 + } + + 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) { + path := filepath.Dir(name) + err := os.MkdirAll(path, sfs.mode) + if err != nil { + return nil, err + } + 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) Remove(name string) error { + return os.Remove(name) +} + +func (sfs *spreadFS) Stat(name string) (fscache.FileInfo, error) { + stat, err := os.Stat(name) + if err != nil { + return fscache.FileInfo{}, err + } + return fscache.FileInfo{FileInfo: stat, Atime: atime.Get(stat)}, nil +} + +func (sfs *spreadFS) RemoveAll() error { + if err := os.RemoveAll(sfs.root); err != nil { + return err + } + return sfs.init() +} + +func (sfs *spreadFS) KeyMapper(key string) string { + // When running the Haunter, fscache can call this KeyMapper with the cached filepath instead of the key. + // That's because we don't inform the original cache keys when reloading in the Reload function above. + // If that's the case, just return the file path, as it is the actual mapped key. + if strings.HasPrefix(key, sfs.root) { + return key + } + hash := fmt.Sprintf("%x", sha1.Sum([]byte(key))) + return filepath.Join(sfs.root, hash[0:2], hash[2:4], hash) +} diff --git a/utils/cache/spread_fs_test.go b/utils/cache/spread_fs_test.go new file mode 100644 index 0000000..2768ea2 --- /dev/null +++ b/utils/cache/spread_fs_test.go @@ -0,0 +1,69 @@ +package cache + +import ( + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Spread FS", func() { + var fs *spreadFS + var rootDir string + + BeforeEach(func() { + var err error + rootDir, _ = os.MkdirTemp("", "spread_fs") + fs, err = NewSpreadFS(rootDir, 0755) + Expect(err).To(BeNil()) + }) + AfterEach(func() { + _ = os.RemoveAll(rootDir) + }) + + Describe("KeyMapper", func() { + It("creates a file with proper name format", func() { + mapped := fs.KeyMapper("abc") + Expect(mapped).To(HavePrefix(fs.root)) + mapped = strings.TrimPrefix(mapped, fs.root) + parts := strings.Split(mapped, string(filepath.Separator)) + Expect(parts).To(HaveLen(4)) + Expect(parts[3]).To(HaveLen(40)) + }) + It("returns the unmodified key if it is a cache file path", func() { + mapped := fs.KeyMapper("abc") + Expect(mapped).To(HavePrefix(fs.root)) + Expect(fs.KeyMapper(mapped)).To(Equal(mapped)) + }) + }) + + Describe("Reload", func() { + var files []string + + 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 string, name string) { + Expect(key).To(Equal(name)) + data, err := os.ReadFile(name) + Expect(err).To(BeNil()) + actual = append(actual, string(data)) + }) + Expect(err).To(BeNil()) + Expect(actual).To(HaveLen(len(files))) + Expect(actual).To(ContainElements(files[0], files[1], files[2])) + }) + }) +}) diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go new file mode 100644 index 0000000..7b4786e --- /dev/null +++ b/utils/chrono/meter.go @@ -0,0 +1,34 @@ +package chrono + +import ( + "time" + + . "github.com/navidrome/navidrome/utils/gg" +) + +// Meter is a simple stopwatch +type Meter struct { + elapsed time.Duration + mark *time.Time +} + +func (m *Meter) Start() { + m.mark = P(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 new file mode 100644 index 0000000..8e18d37 --- /dev/null +++ b/utils/chrono/meter_test.go @@ -0,0 +1,91 @@ +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/context.go b/utils/context.go new file mode 100644 index 0000000..c6f1ef7 --- /dev/null +++ b/utils/context.go @@ -0,0 +1,12 @@ +package utils + +import "context" + +func IsCtxDone(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + return false + } +} diff --git a/utils/context_test.go b/utils/context_test.go new file mode 100644 index 0000000..b2a7617 --- /dev/null +++ b/utils/context_test.go @@ -0,0 +1,23 @@ +package utils_test + +import ( + "context" + + "github.com/navidrome/navidrome/utils" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("IsCtxDone", func() { + It("returns false if the context is not done", func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + Expect(utils.IsCtxDone(ctx)).To(BeFalse()) + }) + + It("returns true if the context is done", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + Expect(utils.IsCtxDone(ctx)).To(BeTrue()) + }) +}) diff --git a/utils/encrypt.go b/utils/encrypt.go new file mode 100644 index 0000000..d2d228c --- /dev/null +++ b/utils/encrypt.go @@ -0,0 +1,72 @@ +package utils + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + + "github.com/navidrome/navidrome/log" +) + +func Encrypt(ctx context.Context, encKey []byte, data string) (string, error) { + plaintext := []byte(data) + + block, err := aes.NewCipher(encKey) + if err != nil { + log.Error(ctx, "Could not create a cipher", err) + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + log.Error(ctx, "Could not create a GCM", "user", err) + return "", err + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + log.Error(ctx, "Could generate nonce", err) + return "", err + } + + ciphertext := aesGCM.Seal(nonce, nonce, plaintext, nil) + 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") + } + }() + + enc, _ := base64.StdEncoding.DecodeString(encData) + + block, err := aes.NewCipher(encKey) + if err != nil { + log.Error(ctx, "Could not create a cipher", err) + return "", err + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + log.Error(ctx, "Could not create a GCM", err) + return "", err + } + + nonceSize := aesGCM.NonceSize() + nonce, ciphertext := enc[:nonceSize], enc[nonceSize:] + + plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil) + if err != nil { + log.Error(ctx, "Could not decrypt password", err) + return "", err + } + + return string(plaintext), nil +} diff --git a/utils/encrypt_test.go b/utils/encrypt_test.go new file mode 100644 index 0000000..5cd0a8c --- /dev/null +++ b/utils/encrypt_test.go @@ -0,0 +1,38 @@ +package utils + +import ( + "context" + "crypto/sha256" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("encrypt", func() { + It("decrypts correctly when using the same encryption key", func() { + sum := sha256.Sum256([]byte("password")) + encKey := sum[0:] + data := "Can you keep a secret?" + + encrypted, err := Encrypt(context.Background(), encKey, data) + Expect(err).ToNot(HaveOccurred()) + decrypted, err := Decrypt(context.Background(), encKey, encrypted) + Expect(err).ToNot(HaveOccurred()) + + Expect(decrypted).To(Equal(data)) + }) + + It("fails to decrypt if not using the same encryption key", func() { + sum := sha256.Sum256([]byte("password")) + encKey := sum[0:] + data := "Can you keep a secret?" + + encrypted, err := Encrypt(context.Background(), encKey, data) + Expect(err).ToNot(HaveOccurred()) + + sum = sha256.Sum256([]byte("different password")) + encKey = sum[0:] + _, err = Decrypt(context.Background(), encKey, encrypted) + Expect(err).To(MatchError("cipher: message authentication failed")) + }) +}) diff --git a/utils/files.go b/utils/files.go new file mode 100644 index 0000000..9bdc262 --- /dev/null +++ b/utils/files.go @@ -0,0 +1,25 @@ +package utils + +import ( + "os" + "path" + "path/filepath" + "strings" + + "github.com/navidrome/navidrome/model/id" +) + +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)) +} + +// 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 new file mode 100644 index 0000000..dcb28aa --- /dev/null +++ b/utils/files_test.go @@ -0,0 +1,178 @@ +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("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()) + + 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 new file mode 100644 index 0000000..208fe29 --- /dev/null +++ b/utils/gg/gg.go @@ -0,0 +1,23 @@ +// Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog +package gg + +// P returns a pointer to the input value +func P[T any](v T) *T { + return &v +} + +// 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 p == nil { + var zero T + return zero + } + return *p +} + +func If[T any](cond bool, v1, v2 T) T { + if cond { + return v1 + } + return v2 +} diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go new file mode 100644 index 0000000..1d6dff4 --- /dev/null +++ b/utils/gg/gg_test.go @@ -0,0 +1,62 @@ +package gg_test + +import ( + "testing" + + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gg" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGG(t *testing.T) { + tests.Init(t, false) + RegisterFailHandler(Fail) + RunSpecs(t, "GG Suite") +} + +var _ = Describe("GG", func() { + Describe("P", func() { + It("returns a pointer to the input value", func() { + v := 123 + Expect(gg.P(123)).To(Equal(&v)) + }) + + It("returns nil if the input value is zero", func() { + v := 0 + Expect(gg.P(0)).To(Equal(&v)) + }) + }) + + Describe("V", func() { + It("returns the value of the input pointer", func() { + v := 123 + Expect(gg.V(&v)).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)) + }) + + It("returns the second value if the condition is false", func() { + Expect(gg.If(false, 1, 2)).To(Equal(2)) + }) + + 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)) + }) + }) +}) diff --git a/utils/gravatar/gravatar.go b/utils/gravatar/gravatar.go new file mode 100644 index 0000000..1ab2464 --- /dev/null +++ b/utils/gravatar/gravatar.go @@ -0,0 +1,23 @@ +package gravatar + +import ( + "crypto/sha256" + "fmt" + "strings" +) + +const baseUrl = "https://www.gravatar.com/avatar" +const defaultSize = 80 +const maxSize = 2048 + +func Url(email string, size int) string { + email = strings.ToLower(email) + email = strings.TrimSpace(email) + hash := sha256.Sum256([]byte(email)) + if size < 1 { + size = defaultSize + } + size = 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 new file mode 100644 index 0000000..b829891 --- /dev/null +++ b/utils/gravatar/gravatar_test.go @@ -0,0 +1,34 @@ +package gravatar_test + +import ( + "testing" + + "github.com/navidrome/navidrome/tests" + "github.com/navidrome/navidrome/utils/gravatar" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGravatar(t *testing.T) { + tests.Init(t, false) + 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")) + }) + It("sets the default size", func() { + Expect(gravatar.Url("my@email.com", 0)).To(Equal("https://www.gravatar.com/avatar/cf3d8259741b19a2b09e17d4fa9a97c63adc44bf2a5fa075cdcb5491f525feaa?s=80")) + }) + It("caps maximum size", func() { + Expect(gravatar.Url("my@email.com", 3000)).To(Equal("https://www.gravatar.com/avatar/cf3d8259741b19a2b09e17d4fa9a97c63adc44bf2a5fa075cdcb5491f525feaa?s=2048")) + }) + It("ignores case", func() { + Expect(gravatar.Url("MY@email.com", 0)).To(Equal(gravatar.Url("my@email.com", 0))) + }) + It("ignores spaces", 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 new file mode 100644 index 0000000..279cbe4 --- /dev/null +++ b/utils/hasher/hasher.go @@ -0,0 +1,74 @@ +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 new file mode 100644 index 0000000..30cda3d --- /dev/null +++ b/utils/hasher/hasher_test.go @@ -0,0 +1,68 @@ +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 := 0; i < 1000; i++ { + go func() { + hashFunc := hasher.HashFunc() + sum := hashFunc(strconv.Itoa(i), input) + Expect(sum).ToNot(BeZero()) + }() + } + }) +}) diff --git a/utils/index_group_parser.go b/utils/index_group_parser.go new file mode 100644 index 0000000..5622164 --- /dev/null +++ b/utils/index_group_parser.go @@ -0,0 +1,38 @@ +package utils + +import ( + "regexp" + "strings" +) + +type IndexGroups map[string]string + +// ParseIndexGroups +// The specification is a space-separated list of index entries. Normally, each entry is just a single character, +// but you may also specify multiple characters. For instance, the entry "The" will link to all files and +// folders starting with "The". +// +// You may also create an entry using a group of index characters in parentheses. For instance, the entry +// "A-E(ABCDE)" will display as "A-E" and link to all files and folders starting with either +// A, B, C, D or E. This may be useful for grouping less-frequently used characters (such and X, Y and Z), or +// 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.Split(spec, " ") + for _, g := range split { + sub := indexGroupsRx.FindStringSubmatch(g) + if len(sub) > 0 { + for _, c := range sub[2] { + parsed[string(c)] = sub[1] + } + } else { + parsed[g] = g + } + } + return parsed +} diff --git a/utils/index_group_parser_test.go b/utils/index_group_parser_test.go new file mode 100644 index 0000000..8bed12d --- /dev/null +++ b/utils/index_group_parser_test.go @@ -0,0 +1,38 @@ +package utils + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ParseIndexGroup", func() { + Context("Two simple entries", func() { + It("returns the entries", func() { + parsed := ParseIndexGroups("A The") + + Expect(parsed).To(HaveLen(2)) + Expect(parsed["A"]).To(Equal("A")) + Expect(parsed["The"]).To(Equal("The")) + }) + }) + Context("An entry with a group", func() { + parsed := ParseIndexGroups("A-C(ABC) Z") + + It("parses the groups correctly", func() { + Expect(parsed).To(HaveLen(4)) + Expect(parsed["A"]).To(Equal("A-C")) + Expect(parsed["B"]).To(Equal("A-C")) + Expect(parsed["C"]).To(Equal("A-C")) + Expect(parsed["Z"]).To(Equal("Z")) + }) + }) + Context("Correctly parses UTF-8", func() { + parsed := ParseIndexGroups("UTF8(宇A海)") + It("parses the groups correctly", func() { + Expect(parsed).To(HaveLen(3)) + Expect(parsed["宇"]).To(Equal("UTF8")) + Expect(parsed["A"]).To(Equal("UTF8")) + Expect(parsed["海"]).To(Equal("UTF8")) + }) + }) +}) diff --git a/utils/ioutils/ioutils.go b/utils/ioutils/ioutils.go new file mode 100644 index 0000000..89d3997 --- /dev/null +++ b/utils/ioutils/ioutils.go @@ -0,0 +1,33 @@ +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 new file mode 100644 index 0000000..7f54838 --- /dev/null +++ b/utils/ioutils/ioutils_test.go @@ -0,0 +1,117 @@ +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/limiter.go b/utils/limiter.go new file mode 100644 index 0000000..84153e5 --- /dev/null +++ b/utils/limiter.go @@ -0,0 +1,26 @@ +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/merge_fs.go new file mode 100644 index 0000000..b3b02e5 --- /dev/null +++ b/utils/merge/merge_fs.go @@ -0,0 +1,100 @@ +package merge + +import ( + "cmp" + "errors" + "io" + "io/fs" + "maps" + "slices" +) + +// FS 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 { + Base fs.FS + Overlay fs.FS +} + +func (m FS) Open(name string) (fs.File, error) { + file, err := m.Overlay.Open(name) + if err != nil { + return m.Base.Open(name) + } + + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + overlayDirFile, ok := file.(fs.ReadDirFile) + if !info.IsDir() || !ok { + return file, nil + } + + baseDir, _ := m.Base.Open(name) + defer func() { + _ = baseDir.Close() + _ = file.Close() + }() + baseDirFile, ok := baseDir.(fs.ReadDirFile) + if !ok { + return nil, fs.ErrInvalid + } + 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) { + baseFiles, err := baseDir.ReadDir(-1) + if err != nil { + return nil, err + } + + overlayFiles, err := overlayDir.ReadDir(-1) + if err != nil { + overlayFiles = nil + } + + 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()) }) + return &mergedDir{ + name: name, + info: info, + entries: entries, + }, nil +} + +type mergedDir struct { + name string + info fs.FileInfo + entries []fs.DirEntry + pos int +} + +var _ fs.ReadDirFile = (*mergedDir)(nil) + +func (d *mergedDir) ReadDir(count int) ([]fs.DirEntry, error) { + if d.pos >= len(d.entries) && count > 0 { + return nil, io.EOF + } + if count <= 0 || count > len(d.entries)-d.pos { + count = len(d.entries) - d.pos + } + entries := d.entries[d.pos : d.pos+count] + d.pos += count + return entries, nil +} + +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) { + 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/merge_fs_test.go new file mode 100644 index 0000000..7241308 --- /dev/null +++ b/utils/merge/merge_fs_test.go @@ -0,0 +1,117 @@ +package merge_test + +import ( + "io" + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/navidrome/navidrome/utils/merge" + . "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 baseName, overlayName string + var 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} + }) + AfterEach(func() { + _ = os.RemoveAll(baseName) + _ = os.RemoveAll(overlayName) + }) + + It("reads from Base dir if not found in Overlay", func() { + _f(baseName, "a.json") + file, err := mergedDir.Open("a.json") + Expect(err).To(BeNil()) + + stat, err := file.Stat() + Expect(err).To(BeNil()) + + Expect(stat.Name()).To(Equal("a.json")) + }) + + It("reads overridden file", func() { + _f(baseName, "b.json", "original") + _f(baseName, "b.json", "overridden") + + file, err := mergedDir.Open("b.json") + Expect(err).To(BeNil()) + + content, err := io.ReadAll(file) + Expect(err).To(BeNil()) + Expect(string(content)).To(Equal("overridden")) + }) + + It("reads only files from Base if Overlay is empty", func() { + _f(baseName, "test.txt") + + dir, err := mergedDir.Open(".") + Expect(err).To(BeNil()) + + list, err := dir.(fs.ReadDirFile).ReadDir(-1) + Expect(err).To(BeNil()) + + Expect(list).To(HaveLen(1)) + Expect(list[0].Name()).To(Equal("test.txt")) + }) + + It("reads merged dirs", func() { + _f(baseName, "1111.txt") + _f(overlayName, "2222.json") + + dir, err := mergedDir.Open(".") + Expect(err).To(BeNil()) + + list, err := dir.(fs.ReadDirFile).ReadDir(-1) + Expect(err).To(BeNil()) + + Expect(list).To(HaveLen(2)) + Expect(list[0].Name()).To(Equal("1111.txt")) + Expect(list[1].Name()).To(Equal("2222.json")) + }) + + It("allows to seek to the beginning of the directory", func() { + _f(baseName, "1111.txt") + _f(baseName, "2222.txt") + _f(baseName, "3333.txt") + + 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")) + + list, _ = dir.(fs.ReadDirFile).ReadDir(2) + Expect(list).To(HaveLen(1)) + Expect(list[0].Name()).To(Equal("3333.txt")) + }) +}) + +func _f(dir, name string, content ...string) string { + path := filepath.Join(dir, name) + file, err := os.Create(path) + if err != nil { + panic(err) + } + if len(content) > 0 { + _, _ = file.WriteString(content[0]) + } + _ = file.Close() + return path +} diff --git a/utils/number/number.go b/utils/number/number.go new file mode 100644 index 0000000..5176a83 --- /dev/null +++ b/utils/number/number.go @@ -0,0 +1,12 @@ +package number + +import ( + "strconv" + + "golang.org/x/exp/constraints" +) + +func ParseInt[T constraints.Integer](s string) T { + r, _ := strconv.ParseInt(s, 10, 64) + return T(r) +} diff --git a/utils/number/number_test.go b/utils/number/number_test.go new file mode 100644 index 0000000..dd66148 --- /dev/null +++ b/utils/number/number_test.go @@ -0,0 +1,31 @@ +package number_test + +import ( + "testing" + + "github.com/navidrome/navidrome/utils/number" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestNumber(t *testing.T) { + RegisterFailHandler(Fail) + 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))) + }) + }) +}) diff --git a/utils/pl/pipelines.go b/utils/pl/pipelines.go new file mode 100644 index 0000000..ed85c6b --- /dev/null +++ b/utils/pl/pipelines.go @@ -0,0 +1,176 @@ +// Package pl implements some Data Pipeline helper functions. +// Reference: https://medium.com/amboss/applying-modern-go-concurrency-patterns-to-data-pipelines-b3b5327908d4#3a80 +// +// See also: +// +// https://www.oreilly.com/library/view/concurrency-in-go/9781491941294/ch04.html#fano_fani +// https://www.youtube.com/watch?v=f6kdp27TYZs +// https://www.youtube.com/watch?v=QDDwwePbDtw +package pl + +import ( + "context" + "errors" + "sync" + + "github.com/navidrome/navidrome/log" + "golang.org/x/sync/semaphore" +) + +func Stage[In any, Out any]( + ctx context.Context, + maxWorkers int, + inputChannel <-chan In, + fn func(context.Context, In) (Out, error), +) (chan Out, chan error) { + outputChannel := make(chan Out) + errorChannel := make(chan error) + + limit := int64(maxWorkers) + sem1 := semaphore.NewWeighted(limit) + + go func() { + defer close(outputChannel) + defer close(errorChannel) + + for s := range ReadOrDone(ctx, inputChannel) { + if err := sem1.Acquire(ctx, 1); err != nil { + if !errors.Is(err, context.Canceled) { + log.Error(ctx, "Failed to acquire semaphore", err) + } + break + } + + go func(s In) { + defer sem1.Release(1) + + result, err := fn(ctx, s) + if err != nil { + if !errors.Is(err, context.Canceled) { + errorChannel <- err + } + } else { + outputChannel <- result + } + }(s) + } + + // 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 { + log.Error(ctx, "Failed waiting for workers", err) + } + }() + + return outputChannel, errorChannel +} + +func Sink[In any]( + ctx context.Context, + maxWorkers int, + inputChannel <-chan In, + fn func(context.Context, In) error, +) chan error { + results, errC := Stage(ctx, maxWorkers, inputChannel, func(ctx context.Context, in In) (bool, error) { + err := fn(ctx, in) + return false, err // Only err is important, results will be discarded + }) + + // Discard results + go func() { + for range ReadOrDone(ctx, results) { + } + }() + + return errC +} + +func Merge[T any](ctx context.Context, cs ...<-chan T) <-chan T { + var wg sync.WaitGroup + out := make(chan T) + + output := func(c <-chan T) { + defer wg.Done() + for v := range ReadOrDone(ctx, c) { + select { + case out <- v: + case <-ctx.Done(): + return + } + } + } + + wg.Add(len(cs)) + for _, c := range cs { + go output(c) + } + + go func() { + wg.Wait() + close(out) + }() + + return out +} + +func SendOrDone[T any](ctx context.Context, out chan<- T, v T) { + select { + case out <- v: + case <-ctx.Done(): + return + } +} + +func ReadOrDone[T any](ctx context.Context, in <-chan T) <-chan T { + valStream := make(chan T) + go func() { + defer close(valStream) + for { + select { + case <-ctx.Done(): + return + case v, ok := <-in: + if !ok { + return + } + select { + case valStream <- v: + case <-ctx.Done(): + } + } + } + }() + return valStream +} + +func Tee[T any](ctx context.Context, in <-chan T) (<-chan T, <-chan T) { + out1 := make(chan T) + out2 := make(chan T) + go func() { + defer close(out1) + defer close(out2) + for val := range ReadOrDone(ctx, in) { + var out1, out2 = out1, out2 + for i := 0; i < 2; i++ { + select { + case <-ctx.Done(): + case out1 <- val: + out1 = nil + case out2 <- val: + out2 = nil + } + } + } + }() + return out1, out2 +} + +func FromSlice[T any](ctx context.Context, in []T) <-chan T { + output := make(chan T, len(in)) + for _, c := range in { + output <- c + } + close(output) + return output +} diff --git a/utils/pl/pipelines_test.go b/utils/pl/pipelines_test.go new file mode 100644 index 0000000..f5da6e4 --- /dev/null +++ b/utils/pl/pipelines_test.go @@ -0,0 +1,168 @@ +package pl_test + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/navidrome/navidrome/utils/pl" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestPipeline(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Pipeline Tests Suite") +} + +var _ = Describe("Pipeline", func() { + Describe("Stage", func() { + Context("happy path", func() { + It("calls the 'transform' function and returns values and errors", func() { + inC := make(chan int, 4) + for i := 0; i < 4; i++ { + inC <- i + } + close(inC) + + outC, errC := pl.Stage(context.Background(), 1, inC, func(ctx context.Context, i int) (int, error) { + if i%2 == 0 { + return 0, errors.New("even number") + } + return i * 2, nil + }) + + Expect(<-errC).To(MatchError("even number")) + Expect(<-outC).To(Equal(2)) + Expect(<-errC).To(MatchError("even number")) + Expect(<-outC).To(Equal(6)) + + Eventually(outC).Should(BeClosed()) + Eventually(errC).Should(BeClosed()) + }) + }) + Context("Multiple workers", func() { + const maxWorkers = 2 + const numJobs = 100 + It("starts multiple workers, respecting the limit", func() { + inC := make(chan int, numJobs) + for i := 0; i < numJobs; i++ { + inC <- i + } + close(inC) + + current := atomic.Int32{} + count := atomic.Int32{} + max := atomic.Int32{} + outC, _ := pl.Stage(context.Background(), maxWorkers, inC, func(ctx context.Context, in int) (int, error) { + defer current.Add(-1) + c := current.Add(1) + count.Add(1) + if c > max.Load() { + max.Store(c) + } + time.Sleep(10 * time.Millisecond) // Slow process + return 0, nil + }) + // Discard output and wait for completion + for range outC { + } + + Expect(count.Load()).To(Equal(int32(numJobs))) + Expect(current.Load()).To(Equal(int32(0))) + Expect(max.Load()).To(Equal(int32(maxWorkers))) + }) + }) + When("the context is canceled", func() { + It("closes its output", func() { + ctx, cancel := context.WithCancel(context.Background()) + inC := make(chan int) + outC, errC := pl.Stage(ctx, 1, inC, func(ctx context.Context, i int) (int, error) { + return i, nil + }) + cancel() + Eventually(outC).Should(BeClosed()) + Eventually(errC).Should(BeClosed()) + }) + }) + + }) + Describe("Merge", func() { + var in1, in2 chan int + BeforeEach(func() { + in1 = make(chan int, 4) + in2 = make(chan int, 4) + for i := 0; i < 4; i++ { + in1 <- i + in2 <- i + 4 + } + close(in1) + close(in2) + }) + When("ranging through the output channel", func() { + It("copies values from all input channels to its output channel", func() { + var values []int + for v := range pl.Merge(context.Background(), in1, in2) { + values = append(values, v) + } + + Expect(values).To(ConsistOf(0, 1, 2, 3, 4, 5, 6, 7)) + }) + }) + When("there's a blocked channel and the context is closed", func() { + It("closes its output", func() { + ctx, cancel := context.WithCancel(context.Background()) + in3 := make(chan int) + out := pl.Merge(ctx, in1, in2, in3) + cancel() + Eventually(out).Should(BeClosed()) + }) + }) + }) + Describe("ReadOrDone", func() { + When("values are sent", func() { + It("copies them to its output channel", func() { + in := make(chan int) + out := pl.ReadOrDone(context.Background(), in) + for i := 0; i < 4; i++ { + in <- i + j := <-out + Expect(i).To(Equal(j)) + } + close(in) + Eventually(out).Should(BeClosed()) + }) + }) + When("the context is canceled", func() { + It("closes its output", func() { + ctx, cancel := context.WithCancel(context.Background()) + in := make(chan int) + out := pl.ReadOrDone(ctx, in) + cancel() + Eventually(out).Should(BeClosed()) + }) + }) + }) + Describe("SendOrDone", func() { + When("out is unblocked", func() { + It("puts the value in the channel", func() { + out := make(chan int) + value := 1234 + go pl.SendOrDone(context.Background(), out, value) + Eventually(out).Should(Receive(&value)) + }) + }) + When("out is blocked", func() { + It("can be canceled by the context", func() { + ctx, cancel := context.WithCancel(context.Background()) + out := make(chan int) + go pl.SendOrDone(ctx, out, 1234) + cancel() + + Consistently(out).ShouldNot(Receive()) + }) + }) + }) +}) diff --git a/utils/random/number.go b/utils/random/number.go new file mode 100644 index 0000000..80c242c --- /dev/null +++ b/utils/random/number.go @@ -0,0 +1,24 @@ +package random + +import ( + "crypto/rand" + "encoding/binary" + "math/big" + + "golang.org/x/exp/constraints" +) + +// 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 constraints.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 new file mode 100644 index 0000000..b591ae2 --- /dev/null +++ b/utils/random/number_test.go @@ -0,0 +1,24 @@ +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 i := 0; i < 10000; i++ { + Expect(random.Int64N(100)).To(BeNumerically("<", 100)) + } + }) + }) +}) diff --git a/utils/random/weighted_random_chooser.go b/utils/random/weighted_random_chooser.go new file mode 100644 index 0000000..f382ab4 --- /dev/null +++ b/utils/random/weighted_random_chooser.go @@ -0,0 +1,70 @@ +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 new file mode 100644 index 0000000..026ee92 --- /dev/null +++ b/utils/random/weighted_random_chooser_test.go @@ -0,0 +1,72 @@ +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 := 0; i < 10; i++ { + 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 i := 0; i < 10; i++ { + 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 i := 0; i < 10; i++ { + 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 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])) + } + }) +}) diff --git a/utils/req/req.go b/utils/req/req.go new file mode 100644 index 0000000..f9fa572 --- /dev/null +++ b/utils/req/req.go @@ -0,0 +1,172 @@ +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 { + s := r.URL.Query().Get(param) + v = &s + } + 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) + b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/") + v = &b + } + return v +} + +func (r *Values) StringOr(param, def string) string { + v, _ := r.String(param) + if v == "" { + return def + } + return v +} + +func (r *Values) Strings(param string) ([]string, error) { + values := r.URL.Query()[param] + if len(values) == 0 { + return nil, newError(ErrMissingParam, param) + } + return values, nil +} + +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, err := r.Strings(param) + if err != nil { + return nil, err + } + 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, err := r.Strings(param) + if err != nil { + return nil, err + } + 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 +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go new file mode 100644 index 0000000..e710365 --- /dev/null +++ b/utils/req/req_test.go @@ -0,0 +1,277 @@ +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() { + v, err := r.Strings("xx") + Expect(err).To(MatchError(req.ErrMissingParam)) + Expect(v).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("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()) + }) + }) +}) diff --git a/utils/run/run.go b/utils/run/run.go new file mode 100644 index 0000000..182eec4 --- /dev/null +++ b/utils/run/run.go @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..07d2d39 --- /dev/null +++ b/utils/run/run_test.go @@ -0,0 +1,171 @@ +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/singleton/singleton.go b/utils/singleton/singleton.go new file mode 100644 index 0000000..1066ae6 --- /dev/null +++ b/utils/singleton/singleton.go @@ -0,0 +1,69 @@ +package singleton + +import ( + "fmt" + "reflect" + "sync" + + "github.com/navidrome/navidrome/log" +) + +var ( + instances = map[string]interface{}{} + pending = map[string]chan struct{}{} + lock sync.RWMutex +) + +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) + } + 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 +} diff --git a/utils/singleton/singleton_test.go b/utils/singleton/singleton_test.go new file mode 100644 index 0000000..c58bafd --- /dev/null +++ b/utils/singleton/singleton_test.go @@ -0,0 +1,102 @@ +package singleton_test + +import ( + "sync" + "sync/atomic" + "testing" + + "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/utils/singleton" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSingleton(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Singleton Suite") +} + +var _ = Describe("GetInstance", func() { + type T struct{ id string } + var numInstancesCreated int + constructor := func() *T { + numInstancesCreated++ + return &T{id: id.NewRandom()} + } + + It("calls the constructor to create a new instance", func() { + instance := singleton.GetInstance(constructor) + Expect(numInstancesCreated).To(Equal(1)) + Expect(instance).To(BeAssignableToTypeOf(&T{})) + }) + + It("does not call the constructor the next time", func() { + instance := singleton.GetInstance(constructor) + newInstance := singleton.GetInstance(constructor) + + Expect(newInstance.id).To(Equal(instance.id)) + Expect(numInstancesCreated).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()} + }) + + Expect(instance).To(BeAssignableToTypeOf(&T{})) + Expect(newInstance).To(BeAssignableToTypeOf(T{})) + Expect(newInstance.id).ToNot(Equal(instance.id)) + Expect(numInstancesCreated).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 + start := sync.WaitGroup{} + start.Add(1) + + // This WaitGroup is used to wait for all goroutines to be done + done := sync.WaitGroup{} + done.Add(numCallsToDo) + + numInstancesCreated = 0 + for i := 0; i < numCallsToDo; 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} + }) + // Increment the number of calls done + numCallsDone.Add(1) + + // Flag the main WaitGroup that this goroutine is done + 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 + + Expect(numCallsDone.Load()).To(Equal(uint32(numCallsToDo))) + Expect(numInstancesCreated).To(Equal(1)) + }) +}) diff --git a/utils/slice/slice.go b/utils/slice/slice.go new file mode 100644 index 0000000..b1f50af --- /dev/null +++ b/utils/slice/slice.go @@ -0,0 +1,184 @@ +package slice + +import ( + "bufio" + "bytes" + "cmp" + "io" + "iter" + "slices" + + "golang.org/x/exp/maps" +) + +func Map[T any, R any](t []T, mapFunc func(T) R) []R { + r := make([]R, len(t)) + for i, e := range t { + r[i] = mapFunc(e) + } + 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 { + k := keyFunc(item) + m[k] = append(m[k], item) + } + return m +} + +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 +} + +func CompactByFrequency[T comparable](list []T) []T { + counters := make(map[T]int) + for _, item := range list { + counters[item]++ + } + + sorted := maps.Keys(counters) + slices.SortFunc(sorted, func(i, j T) int { + return cmp.Compare(counters[j], counters[i]) + }) + return sorted +} + +func MostFrequent[T comparable](list []T) T { + var zero T + if len(list) == 0 { + return zero + } + + counters := make(map[T]int) + var topItem T + var topCount int + + for _, value := range list { + if value == zero { + continue + } + counters[value]++ + if counters[value] > topCount { + topItem = value + topCount = counters[value] + } + } + + return topItem +} + +func Insert[T any](slice []T, value T, index int) []T { + return append(slice[:index], append([]T{value}, slice[index:]...)...) +} + +func Remove[T any](slice []T, index int) []T { + return append(slice[:index], slice[index+1:]...) +} + +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 new file mode 100644 index 0000000..65e5f09 --- /dev/null +++ b/utils/slice/slice_test.go @@ -0,0 +1,213 @@ +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") +} + +var _ = Describe("Slice Utils", func() { + Describe("Map", func() { + It("returns empty slice for an empty input", func() { + mapFunc := func(v int) string { return strconv.Itoa(v * 2) } + result := slice.Map([]int{}, mapFunc) + Expect(result).To(BeEmpty()) + }) + + It("returns a new slice with elements mapped", func() { + mapFunc := func(v int) string { return strconv.Itoa(v * 2) } + result := slice.Map([]int{1, 2, 3, 4}, mapFunc) + Expect(result).To(ConsistOf("2", "4", "6", "8")) + }) + }) + + 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 } + result := slice.Group([]int{}, keyFunc) + Expect(result).To(BeEmpty()) + }) + + It("groups by the result of the key function", func() { + keyFunc := func(v int) int { return v % 2 } + result := slice.Group([]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11}, keyFunc) + Expect(result).To(HaveLen(2)) + Expect(result[0]).To(ConsistOf(2, 4, 6, 8, 10)) + Expect(result[1]).To(ConsistOf(1, 3, 5, 7, 9, 11)) + }) + }) + + 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("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()) + }) + + It("returns the single item", func() { + Expect(slice.MostFrequent([]string{"123"})).To(Equal("123")) + }) + 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")) + }) + It("moves item to beginning of slice", func() { + Expect(slice.Move([]string{"1", "2", "3"}, 2, 0)).To(HaveExactElements("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)) + }) + }) +}) diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go new file mode 100644 index 0000000..ff8b2fb --- /dev/null +++ b/utils/str/sanitize_strings.go @@ -0,0 +1,65 @@ +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() + +func SanitizeText(text string) string { + s := policy.Sanitize(text) + return html.UnescapeString(s) +} + +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.Split(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 new file mode 100644 index 0000000..ac28fe4 --- /dev/null +++ b/utils/str/sanitize_strings_test.go @@ -0,0 +1,109 @@ +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("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 new file mode 100644 index 0000000..f662473 --- /dev/null +++ b/utils/str/str.go @@ -0,0 +1,64 @@ +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 := maxRunes - suffixRunes + if truncateAt < 0 { + truncateAt = 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 new file mode 100644 index 0000000..15cb127 --- /dev/null +++ b/utils/str/str_suite_test.go @@ -0,0 +1,13 @@ +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 new file mode 100644 index 0000000..5118058 --- /dev/null +++ b/utils/str/str_test.go @@ -0,0 +1,214 @@ +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/time.go b/utils/time.go new file mode 100644 index 0000000..c1e9495 --- /dev/null +++ b/utils/time.go @@ -0,0 +1,13 @@ +package utils + +import "time" + +func TimeNewest(times ...time.Time) time.Time { + newest := time.Time{} + for _, t := range times { + if t.After(newest) { + newest = t + } + } + return newest +} diff --git a/utils/time_test.go b/utils/time_test.go new file mode 100644 index 0000000..f89f0d2 --- /dev/null +++ b/utils/time_test.go @@ -0,0 +1,28 @@ +package utils_test + +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)) + }) +}) diff --git a/utils/utils_suite_test.go b/utils/utils_suite_test.go new file mode 100644 index 0000000..847bc96 --- /dev/null +++ b/utils/utils_suite_test.go @@ -0,0 +1,13 @@ +package utils + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils Suite") +}