Compare commits

..

22 commits

Author SHA1 Message Date
Deluan
7cf98af9da Refactor included handling 2023-06-07 10:49:56 -04:00
Deluan
40f7170930 Add descriptions to included fields 2023-06-07 10:49:56 -04:00
Deluan
4a8f176f0d Add includes for /albums 2023-06-07 10:49:56 -04:00
Deluan
4507895d58 Add includes for /tracks 2023-06-07 10:49:56 -04:00
Deluan
90c2d7d1cf Add Albums endpoints 2023-06-07 10:49:56 -04:00
Deluan
f62231f728 Fix pipeline 2023-06-07 10:49:55 -04:00
Deluan
fd1e06049f Use redocly/cli to bundle/lint OpenAPI spec 2023-06-07 10:49:55 -04:00
Deluan
7dfe29af5e Build OpenAPI.yaml 2023-06-07 10:49:55 -04:00
Deluan
8e03d7d013 Add authorization to new API 2023-06-07 10:49:55 -04:00
Deluan
960415ed95 Update oapi-codegen 2023-06-07 10:49:55 -04:00
Deluan
ea231fe265 Refactor 2023-06-07 10:49:55 -04:00
Deluan
09e52eba87 Change API name 2023-06-07 10:49:55 -04:00
Deluan
2650e4c27c Finish splitting openapi into multiple files 2023-06-07 10:49:55 -04:00
Deluan
3c0f23e3f2 Fix spec paths 2023-06-07 10:49:55 -04:00
Deluan
250b6dbb33 Start breaking OpenAPI spec 2023-06-07 10:49:55 -04:00
Deluan
be945e010a go mod tidy 2023-06-07 10:49:55 -04:00
Deluan
9308127342 Add more tracks attributes 2023-06-07 10:49:55 -04:00
Deluan
141edb881e Add sort param 2023-06-07 10:49:55 -04:00
Deluan
20a1e3160b Add Album and relationships 2023-06-07 10:49:55 -04:00
Deluan
d4c458d193 Add tests 2023-06-07 10:49:55 -04:00
Deluan
ed87e703ff Build collection Links 2023-06-07 10:49:55 -04:00
Deluan
dcb5725642 Add OpenAPI spec for new API, and wire up a new API handler
Based on Aura and JSONAPI
2023-06-07 10:49:55 -04:00
2335 changed files with 68596 additions and 299113 deletions

View file

@ -2,16 +2,19 @@
# [Choice] Go version: 1, 1.15, 1.14
ARG VARIANT="1"
FROM mcr.microsoft.com/vscode/devcontainers/go:${VARIANT}
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}
# [Option] Install Node.js
ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
# Install additional OS packages
# [Optional] Uncomment this section to install additional OS packages.
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends ffmpeg
&& apt-get -y install --no-install-recommends libtag1-dev ffmpeg
# [Optional] Uncomment the next line to use go get to install anything else you need
# RUN go get -x <your-dependency-or-tool>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View file

@ -4,10 +4,10 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
"VARIANT": "1.27",
"VARIANT": "1.20",
// Options
"INSTALL_NODE": "true",
"NODE_VERSION": "v24"
"NODE_VERSION": "v18"
}
},
"workspaceMount": "",
@ -18,46 +18,44 @@
"--volume=${localWorkspaceFolder}:/workspaces/${localWorkspaceFolderBasename}:Z"
],
// Set *default* container specific settings.json values on container create.
"customizations": {
"vscode": {
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"go.useGoProxyToCheckForToolUpdates": false,
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go",
"go.toolsGopath": "/go/bin",
"go.formatTool": "goimports",
"go.lintOnSave": "package",
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go",
"esbenp.prettier-vscode",
"tamasfe.even-better-toml"
]
"settings": {
"terminal.integrated.shell.linux": "/bin/bash",
"go.useGoProxyToCheckForToolUpdates": false,
"go.useLanguageServer": true,
"go.gopath": "/go",
"go.goroot": "/usr/local/go",
"go.toolsGopath": "/go/bin",
"go.formatTool": "goimports",
"go.lintOnSave": "package",
"go.lintTool": "golangci-lint",
"editor.formatOnSave": true,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
}
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go",
"esbenp.prettier-vscode",
"tamasfe.even-better-toml"
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
4533,
4633
],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "make setup-dev",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"remoteEnv": {
"ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data"
}
}
}

View file

@ -1,19 +1,10 @@
.DS_Store
ui/node_modules
ui/build
!ui/build/.gitkeep
Dockerfile
docker-compose*.yml
data
*.db
testDB
navidrome
navidrome.db
navidrome.toml
tmp
!tmp/taglib
dist
binaries
cache
music
music.old
!Dockerfile

6
.github/FUNDING.yml vendored
View file

@ -1,10 +1,10 @@
# These are supported funding model platforms
ko_fi: deluan
github: deluan
open_collective: navidrome
liberapay: deluan
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: deluan
liberapay: deluan
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
issuehunt: # Replace with a single IssueHunt username

View file

@ -1,90 +0,0 @@
name: 'Prepare Docker Buildx environment'
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
inputs:
github_token:
description: 'GitHub token'
required: true
default: ''
hub_repository:
description: 'Docker Hub repository to push images to'
required: false
default: ''
hub_username:
description: 'Docker Hub username'
required: false
default: ''
hub_password:
description: 'Docker Hub password'
required: false
default: ''
outputs:
tags:
description: 'Docker image tags'
value: ${{ steps.meta.outputs.tags }}
labels:
description: 'Docker image labels'
value: ${{ steps.meta.outputs.labels }}
annotations:
description: 'Docker image annotations'
value: ${{ steps.meta.outputs.annotations }}
version:
description: 'Docker image version'
value: ${{ steps.meta.outputs.version }}
hub_repository:
description: 'Docker Hub repository'
value: ${{ env.DOCKER_HUB_REPO }}
hub_enabled:
description: 'Is Docker Hub enabled'
value: ${{ env.DOCKER_HUB_ENABLED }}
runs:
using: 'composite'
steps:
- name: Check Docker Hub configuration
shell: bash
run: |
if [ -z "${{inputs.hub_repository}}" ]; then
echo "DOCKER_HUB_REPO=none" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=false" >> $GITHUB_ENV
else
echo "DOCKER_HUB_REPO=${{inputs.hub_repository}}" >> $GITHUB_ENV
echo "DOCKER_HUB_ENABLED=true" >> $GITHUB_ENV
fi
- name: Login to Docker Hub
if: inputs.hub_username != '' && inputs.hub_password != ''
uses: docker/login-action@v4
with:
username: ${{ inputs.hub_username }}
password: ${{ inputs.hub_password }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ inputs.github_token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
with:
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["mirror.gcr.io"]
- name: Extract metadata for Docker image
id: meta
uses: docker/metadata-action@v6
with:
github-token: ${{ inputs.github_token }}
labels: |
maintainer=deluan@navidrome.org
images: |
name=${{env.DOCKER_HUB_REPO}},enable=${{env.DOCKER_HUB_ENABLED}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=raw,value=develop,enable={{is_default_branch}}

View file

@ -10,13 +10,3 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: docker
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/.github/workflows"
schedule:
interval: weekly
open-pull-requests-limit: 10

View file

@ -1,38 +0,0 @@
### Description
<!-- Please provide a clear and concise description of what this PR does and why it is needed. -->
### Related Issues
<!-- List any related issues, e.g., "Fixes #123" or "Related to #456". -->
### 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
<!-- Describe the steps to test your changes. Include setup, commands, and expected results. -->
### Screenshots / Demos (if applicable)
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
### Additional Notes
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? -->
<!--
**Tips for Contributors:**
- Be concise but thorough.
- If your PR is large, consider breaking it into smaller PRs.
- Tag the maintainer if you need a prompt review.
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
-->

View file

@ -1,60 +0,0 @@
name: Report coverage on PR
on:
workflow_run:
workflows: ['Pipeline: Test, Lint, Build']
types: [completed]
jobs:
comment:
name: Comment coverage report
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
pull-requests: write
env:
COVERAGE_COMMENT: 'true'
steps:
# Only the config, from the base branch: this job holds a write token, so
# it must never check out the fork.
- name: Check out the octocov config
uses: actions/checkout@v7
with:
sparse-checkout: .octocov.yml
sparse-checkout-cone-mode: false
persist-credentials: false
# Into a subdirectory. A pull_request run executes the fork's own copy of
# pipeline.yml, so every file in here is attacker-controlled.
- uses: actions/download-artifact@v8
with:
name: octocov-pr
path: untrusted
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- name: Verify the artifact and take the coverage profile
id: pr
env:
GH_TOKEN: ${{ github.token }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
number=$(head -c 20 untrusted/pr_number | tr -d '[:space:]')
case "$number" in ''|*[!0-9]*)
echo "::error::artifact pr_number is not a number"; exit 1;;
esac
sha=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$number" --jq .head.sha)
if [ "$sha" != "$HEAD_SHA" ]; then
echo "::error::artifact claims PR #$number, but its head $sha is not $HEAD_SHA"; exit 1
fi
cp untrusted/coverage.out coverage.out
echo "number=$number" >> "$GITHUB_OUTPUT"
- uses: k1LoW/octocov-action@v1
env:
# A workflow_run job looks like a push to the default branch. Point
# octocov back at the pull request and at the run that produced it.
GITHUB_PULL_REQUEST_NUMBER: ${{ steps.pr.outputs.number }}
OCTOCOV_GITHUB_REF: refs/pull/${{ steps.pr.outputs.number }}/merge
OCTOCOV_GITHUB_SHA: ${{ github.event.workflow_run.head_sha }}
OCTOCOV_GITHUB_RUN_ID: ${{ github.event.workflow_run.id }}

View file

@ -8,7 +8,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v9
- uses: actions/github-script@v3
with:
# This snippet is public-domain, taken from
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
@ -19,7 +19,8 @@ jobs:
const pull_user_id = ${{github.event.sender.id}};
const issue_number = await (async () => {
for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) {
const pulls = await github.pulls.list({owner, repo});
for await (const {data} of github.paginate.iterator(pulls)) {
for (const pull of data) {
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
return pull.number;
@ -33,24 +34,21 @@ jobs:
return core.error(`No matching pull request found`);
}
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
if (!downloadable.length) {
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
if (!artifacts.length) {
return core.error(`No artifacts found`);
}
const header = `Download the artifacts for this pull request:`;
let body = `${header}\n`;
for (const art of downloadable) {
let body = `Download the artifacts for this pull request:\n`;
for (const art of artifacts) {
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
}
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
// Match on the body too: octocov also comments as github-actions[bot].
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
const {data: comments} = await github.issues.listComments({repo, owner, issue_number});
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
if (existing_comment) {
core.info(`Updating comment ${existing_comment.id}`);
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
} else {
core.info(`Creating a comment`);
await github.rest.issues.createComment({repo, owner, issue_number, body});
await github.issues.createComment({repo, owner, issue_number, body});
}

38
.github/workflows/pipeline.dockerfile vendored Normal file
View file

@ -0,0 +1,38 @@
#####################################################
### Copy platform specific binary
FROM bash as copy-binary
ARG TARGETPLATFORM
RUN echo "Target Platform = ${TARGETPLATFORM}"
COPY dist .
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then cp navidrome_linux_amd64_linux_amd64_v1/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/386" ]; then cp navidrome_linux_386_linux_386/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then cp navidrome_linux_arm64_linux_arm64/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then cp navidrome_linux_arm_linux_arm_6/navidrome /navidrome; fi
RUN if [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then cp navidrome_linux_arm_linux_arm_7/navidrome /navidrome; fi
RUN chmod +x /navidrome
#####################################################
### Build Final Image
FROM alpine as release
LABEL maintainer="deluan@navidrome.org"
# Install ffmpeg and output build config
RUN apk add --no-cache ffmpeg
RUN ffmpeg -buildconf
COPY --from=copy-binary /navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER /music
ENV ND_DATAFOLDER /data
ENV ND_PORT 4533
ENV GODEBUG "asyncpreemptoff=1"
EXPOSE ${ND_PORT}
HEALTHCHECK CMD wget -O- http://localhost:${ND_PORT}/ping || exit 1
WORKDIR /app
ENTRYPOINT ["/app/navidrome"]

View file

@ -1,4 +1,4 @@
name: "Pipeline: Test, Lint, Build"
name: 'Pipeline: Test, Lint, Build'
on:
push:
branches:
@ -9,330 +9,103 @@ on:
branches:
- master
concurrency:
group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }}
cancel-in-progress: true
env:
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
jobs:
git-version:
name: Get version info
runs-on: ubuntu-latest
outputs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true
- name: Show git version info
run: |
echo "git describe (dirty): $(git describe --dirty --always --tags)"
echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)"
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
echo "github_ref: $GITHUB_REF"
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
git tag -l
- name: Determine git current SHA and latest tag
id: git-version
run: |
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [ -n "$GIT_TAG" ]; then
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
GIT_TAG=${GIT_TAG}-SNAPSHOT
fi
echo "GIT_TAG=$GIT_TAG" >> $GITHUB_OUTPUT
fi
GIT_SHA=$(git rev-parse --short HEAD)
PR_NUM=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
if [[ $PR_NUM != "null" ]]; then
GIT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-8)
GIT_SHA="pr-${PR_NUM}/${GIT_SHA}"
fi
echo "GIT_SHA=$GIT_SHA" >> $GITHUB_OUTPUT
echo "GIT_TAG=$GIT_TAG"
echo "GIT_SHA=$GIT_SHA"
go-lint:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install taglib
run: sudo apt-get install libtag1-dev
- uses: actions/setup-go@v6
- name: Set up Go 1.20
uses: actions/setup-go@v3
with:
go-version-file: go.mod
go-version: 1.20.x
# Keep CI on the same version `make lint` installs, so a clean local run
# cannot turn red in CI just because a new golangci-lint was released.
- name: Resolve golangci-lint version
id: golangci-version
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v3
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@v3
with:
version: ${{ steps.golangci-version.outputs.version }}
problem-matchers: true
version: latest
github-token: ${{ secrets.GITHUB_TOKEN }}
args: --timeout 2m
- name: Run go goimports
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports
- run: goimports -w `find . -name '*.go' | grep -v '_gen.go$'`
- run: go mod tidy
- name: Verify no changes from goimports and go mod tidy
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo 'To fix this check, run "make format" and commit the changes'
echo 'To fix this check, run "goimports -w $(find . -name '*.go' | grep -v '_gen.go$') && go mod tidy"'
exit 1
fi
- name: Resolve OpenAPI tool versions
id: api-tools
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
- name: Cache OpenAPI tools
uses: actions/cache@v6
- uses: actions/setup-node@v3
with:
path: bin
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
node-version: 18
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Lint OpenAPI spec
run: make api-lint
- name: Build and Lint OpenAPI spec
run: make lintapi gen
- name: Run go generate
run: |
make api-gen
go generate ./...
- name: Verify no changes from go generate
- name: Verify no changes
run: |
git status --porcelain
if [ -n "$(git status --porcelain)" ]; then
echo 'Generated code is out of date. Run "make gen" and commit the changes'
echo 'Changes to OpenAPI spec caused changes to the code. Please review and commit the changes.'
exit 1
fi
- name: Check for breaking OpenAPI changes
if: github.event_name == 'pull_request'
run: |
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
validate-migrations:
name: Validate DB migrations
runs-on: ubuntu-latest
# PR-only gate is at step level: a job-level skip would propagate through
# the needs chain (actions/runner#491) and skip all release jobs on tag pushes.
steps:
- uses: actions/checkout@v7
if: github.event_name == 'pull_request'
- uses: actions/upload-artifact@v3
with:
fetch-depth: 0
# Refresh the base branch so the check compares against its CURRENT tip,
# not the (possibly stale) commit the PR was opened against.
- name: Fetch latest base branch
if: github.event_name == 'pull_request'
run: git fetch --no-tags origin "+refs/heads/${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }}"
- name: Validate migration ordering and naming
if: github.event_name == 'pull_request'
env:
BASE_REF: origin/${{ github.event.pull_request.base.ref }}
run: ./.github/workflows/validate-migrations.sh
name: openapi.yaml
path: api/openapi.yaml
go:
name: Test Go code
name: Test with Go ${{ matrix.go_version }}
runs-on: ubuntu-latest
strategy:
matrix:
go_version: [1.20.x,1.19.x]
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v7
- name: Install taglib
run: sudo apt-get install libtag1-dev
- uses: actions/setup-go@v6
- name: Check out code into the Go module directory
uses: actions/checkout@v3
- name: Set up Go ${{ matrix.go_version }}
uses: actions/setup-go@v3
with:
go-version-file: go.mod
go-version: ${{ matrix.go_version }}
cache: true
- name: Download dependencies
run: go mod download
# Name must stay unique across the workflow: octocov matches step names
# by name across every job, and waits for each match to finish.
- name: Test with coverage
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
- name: Test ndpgen
run: |
cd plugins/cmd/ndpgen
go test -shuffle=on -v
go build -o ndpgen .
./ndpgen --help
- name: Upload coverage profile
uses: actions/upload-artifact@v7
with:
name: octocov-go
path: coverage.out
if-no-files-found: error
go-plugins:
name: Test Go plugins
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v7
- uses: actions/setup-go@v6
id: setup-go
with:
go-version-file: go.mod
# Without this, the suite recompiles every test plugin WASM module,
# which dominates its runtime under -race.
- name: Cache the WASM compilation cache
uses: actions/cache@v6
with:
path: plugins/testdata/.wazero-cache
key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }}
restore-keys: wazero-${{ runner.os }}-
- name: Test plugins
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/
- name: Upload coverage profile
uses: actions/upload-artifact@v7
with:
name: octocov-plugins
path: coverage.out
if-no-files-found: error
coverage:
name: Report coverage
runs-on: ubuntu-latest
needs: [go, go-plugins]
permissions:
contents: read
actions: write
env:
COVERAGE_COMMENT: 'false'
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
pattern: octocov-*
# Merge here rather than letting octocov do it: octocov reports statement
# coverage for a single profile, but switches to line counting for several.
- name: Merge coverage profiles
run: |
echo "mode: atomic" > coverage.out
awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \
octocov-*/coverage.out | sort >> coverage.out
- uses: k1LoW/octocov-action@v1
- name: Save the PR number for the comment workflow
if: github.event_name == 'pull_request'
run: echo "${{ github.event.pull_request.number }}" > pr_number
- name: Upload the merged profile for the comment workflow
if: github.event_name == 'pull_request'
uses: actions/upload-artifact@v7
with:
name: octocov-pr
path: |
coverage.out
pr_number
if-no-files-found: error
go-windows:
name: Test Go code (Windows)
runs-on: windows-2022
env:
FFMPEG_VERSION: "7.1"
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: msys2/setup-msys2@v2
with:
msystem: MINGW64
install: mingw-w64-x86_64-gcc
update: false
- name: Add mingw64 to PATH
shell: bash
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
- name: Cache ffmpeg
id: ffmpeg-cache
uses: actions/cache@v6
with:
path: C:\ffmpeg
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
- name: Download ffmpeg
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
shell: pwsh
run: |
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
- name: Add ffmpeg to PATH
shell: bash
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
- name: Verify toolchain
shell: pwsh
run: |
go version
where.exe gcc
gcc --version
ffmpeg -version
ffprobe -version
- name: Download dependencies
shell: bash
if: steps.cache-go.outputs.cache-hit != 'true'
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go mod download
- name: Test
shell: bash
env:
CGO_ENABLED: "1"
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
- name: Test ndpgen
shell: bash
run: |
cd plugins/cmd/ndpgen
go test -shuffle=on -v
go build -o ndpgen.exe .
./ndpgen.exe --help
continue-on-error: ${{contains(matrix.go_version, 'beta') || contains(matrix.go_version, 'rc')}}
run: go test -shuffle=on -race -cover ./... -v
js:
name: Test JS code
name: Build JS bundle
runs-on: ubuntu-latest
env:
NODE_OPTIONS: "--max_old_space_size=4096"
NODE_OPTIONS: '--max_old_space_size=4096'
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 24
cache: "npm"
cache-dependency-path: "**/package-lock.json"
node-version: 18
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: npm install dependencies
run: |
@ -354,340 +127,119 @@ jobs:
cd ui
npm run build
i18n-lint:
name: Lint i18n files
- uses: actions/upload-artifact@v3
with:
name: js-bundle
path: ui/build
binaries:
name: Build binaries
needs: [js, go, go-lint]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: |
set -e
for file in resources/i18n/*.json; do
echo "Validating $file"
if ! jq empty "$file" 2>error.log; then
error_message=$(cat error.log)
line_number=$(echo "$error_message" | grep -oP 'line \K[0-9]+')
echo "::error file=$file,line=$line_number::$error_message"
exit 1
fi
done
- run: ./.github/workflows/validate-translations.sh -v
check-push-enabled:
name: Check Docker configuration
runs-on: ubuntu-latest
outputs:
is_enabled: ${{ steps.check.outputs.is_enabled }}
steps:
- name: Check if Docker push is configured
id: check
run: echo "is_enabled=${{ secrets.DOCKER_HUB_USERNAME != '' }}" >> $GITHUB_OUTPUT
build:
name: Build
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
strategy:
matrix:
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
runs-on: ubuntu-latest
env:
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }}
IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }}
DOCKER_BUILD_SUMMARY: false
GIT_SHA: ${{ needs.git-version.outputs.git_sha }}
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
steps:
- name: Sanitize platform name
id: set-platform
run: |
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- uses: actions/checkout@v7
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Build Binaries
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
outputs: |
type=local,dest=./output/${{ env.PLATFORM }}
target: binary
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
- name: Set up QEMU for smoke test
if: env.IS_LINUX == 'true'
uses: docker/setup-qemu-action@v4
# The binary is static, so binfmt+qemu runs it directly on the runner.
# Catches startup crashes in cross-compiled binaries before they ship,
# e.g. the broken ifunc relocations on 32-bit arm from issue #5738.
- name: Smoke-test binary
if: env.IS_LINUX == 'true'
run: |
BIN=./output/${{ env.PLATFORM }}/navidrome
chmod +x "$BIN"
"$BIN" --help >/dev/null
echo "OK: ${{ matrix.platform }} binary starts"
- name: Upload Binaries
uses: actions/upload-artifact@v7
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
retention-days: 7
- name: Build and push image by digest
id: push-image
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.docker.outputs.labels }}
build-args: |
GIT_SHA=${{ env.GIT_SHA }}
GIT_TAG=${{ env.GIT_TAG }}
outputs: |
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push-image.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v7
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
push-manifest-ghcr:
name: Push to GHCR
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- uses: actions/checkout@v7
- name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
push-manifest-dockerhub:
name: Push to Docker Hub
runs-on: ubuntu-latest
permissions:
contents: read
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
continue-on-error: true
steps:
- uses: actions/checkout@v7
- name: Download digests
uses: actions/download-artifact@v8
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to Docker Hub
uses: nick-fields/retry@v4
with:
timeout_minutes: 5
max_attempts: 3
retry_wait_seconds: 30
command: |
cd /tmp/digests
docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
- name: Inspect image in Docker Hub
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
cleanup-digests:
name: Cleanup digest artifacts
runs-on: ubuntu-latest
needs: [push-manifest-ghcr, push-manifest-dockerhub]
if: always() && needs.push-manifest-ghcr.result == 'success'
steps:
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
done
msi:
name: Build Windows installers
needs: [build, git-version]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/download-artifact@v8
with:
path: ./binaries
pattern: navidrome-windows*
merge-multiple: true
- name: Install Wix
run: sudo apt-get install -y wixl jq
- name: Build MSI
env:
GIT_TAG: ${{ needs.git-version.outputs.git_tag }}
run: |
rm -rf binaries/msi
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} 386
sudo GIT_TAG=$GIT_TAG release/wix/build_msi.sh ${GITHUB_WORKSPACE} amd64
du -h binaries/msi/*.msi
- name: Upload MSI files
uses: actions/upload-artifact@v7
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
retention-days: 7
release:
name: Package/Release
needs: [build, msi]
runs-on: ubuntu-latest
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- uses: actions/checkout@v7
- name: Checkout Code
uses: actions/checkout@v3
with:
fetch-depth: 0
fetch-tags: true
- uses: actions/download-artifact@v8
- uses: actions/download-artifact@v3
with:
path: ./binaries
pattern: navidrome-*
merge-multiple: true
name: js-bundle
path: ui/build
- run: ls -lR ./binaries
- name: Set RELEASE_FLAGS for snapshot releases
if: env.IS_RELEASE == 'false'
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7
with:
version: '2.16.0'
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
- name: Config /github/workspace folder as trusted
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Remove build artifacts
run: |
ls -l ./dist
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
uses: actions/upload-artifact@v7
with:
name: packages
path: dist/navidrome_0*
args: /bin/bash -c "git config --global --add safe.directory /github/workspace; git describe --dirty --always --tags"
- id: set-package-list
name: Export list of generated packages
run: |
cd dist
set +x
ITEMS=$(ls navidrome_0* | sed 's/^navidrome_0[^_]*_linux_//' | jq -R -s -c 'split("\n")[:-1]')
echo $ITEMS
echo "package_list=${ITEMS}" >> $GITHUB_OUTPUT
- name: Run GoReleaser - SNAPSHOT
if: startsWith(github.ref, 'refs/tags/') != true
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist --skip-publish --snapshot
upload-packages:
name: Upload Linux PKG
- name: Run GoReleaser - RELEASE
if: startsWith(github.ref, 'refs/tags/')
uses: docker://deluan/ci-goreleaser:1.20.3-1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: goreleaser release --rm-dist
- uses: actions/upload-artifact@v3
with:
name: binaries
path: |
dist
!dist/*.tar.gz
!dist/*.zip
docker:
name: Build and publish Docker images
needs: [binaries]
runs-on: ubuntu-latest
needs: [release]
strategy:
matrix:
item: ${{ fromJson(needs.release.outputs.package_list) }}
env:
DOCKER_IMAGE: ${{secrets.DOCKER_IMAGE}}
steps:
- name: Download all-packages artifact
uses: actions/download-artifact@v8
with:
name: packages
path: ./dist
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
if: env.DOCKER_IMAGE != ''
- name: Upload all-packages artifact
uses: actions/upload-artifact@v7
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
if: env.DOCKER_IMAGE != ''
# delete-artifacts:
# name: Delete unused artifacts
# runs-on: ubuntu-latest
# needs: [upload-packages]
# steps:
# - name: Delete all-packages artifact
# env:
# GH_TOKEN: ${{ github.token }}
# run: |
# for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("packages")) | .id'); do
# gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
# done
- uses: actions/checkout@v3
if: env.DOCKER_IMAGE != ''
- uses: actions/download-artifact@v3
if: env.DOCKER_IMAGE != ''
with:
name: binaries
path: dist
- name: Login to Docker Hub
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: env.DOCKER_IMAGE != ''
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
if: env.DOCKER_IMAGE != ''
id: meta
uses: docker/metadata-action@v4
with:
labels: |
maintainer=deluan
images: |
name=${{secrets.DOCKER_IMAGE}},enable=${{env.GITHUB_REF_TYPE == 'tag' || github.ref == format('refs/heads/{0}', 'master')}}
name=ghcr.io/${{ github.repository }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=raw,value=develop,enable={{is_default_branch}}
- name: Build and Push
if: env.DOCKER_IMAGE != ''
uses: docker/build-push-action@v4
with:
context: .
file: .github/workflows/pipeline.dockerfile
platforms: linux/amd64,linux/386,linux/arm/v6,linux/arm/v7,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}

View file

@ -1,138 +0,0 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Normalize JSON for deterministic comparison:
# remove empty/null attributes, sort keys alphabetically
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
# Get list of all languages configured in the POEditor project
get_language_list() {
curl -s -X POST https://api.poeditor.com/v2/languages/list \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}"
}
# Extract language name from the language list JSON given a language code
get_language_name() {
lang_code="$1"
lang_list="$2"
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
}
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
get_lang_code() {
filepath="$1"
filename=$(basename "$filepath")
echo "${filename%.*}"
}
# Export the current translation for a language from POEditor (v2 API)
export_language() {
lang_code="$1"
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
-d type="key_value_json")
url=$(echo "$response" | jq -r '.result.url')
if [ -z "$url" ] || [ "$url" = "null" ]; then
echo "Failed to export $lang_code: $response" >&2
return 1
fi
echo "$url"
}
# Flatten nested JSON to POEditor languages/update format.
# POEditor uses term + context pairs, where:
# term = the leaf key name
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
flatten_to_poeditor() {
jq -c '[paths(scalars) as $p |
{
"term": ($p | last | tostring),
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
"translation": {"content": getpath($p)}
}
]' "$1"
}
# Update translations for a language in POEditor via languages/update API
update_language() {
lang_code="$1"
file="$2"
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$lang_code" \
--data-urlencode data@/tmp/poeditor_data.json)
rm -f /tmp/poeditor_data.json
status=$(echo "$response" | jq -r '.response.status')
if [ "$status" != "success" ]; then
echo "Failed to update $lang_code: $response" >&2
return 1
fi
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
added=$(echo "$response" | jq -r '.result.translations.added')
updated=$(echo "$response" | jq -r '.result.translations.updated')
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
}
# --- Main ---
if [ $# -eq 0 ]; then
echo "Usage: $0 <file1> [file2] ..."
echo "No files specified. Nothing to do."
exit 0
fi
lang_list=$(get_language_list)
upload_count=0
for file in "$@"; do
if [ ! -f "$file" ]; then
echo "Warning: File not found: $file, skipping"
continue
fi
lang_code=$(get_lang_code "$file")
lang_name=$(get_language_name "$lang_code" "$lang_list")
if [ -z "$lang_name" ]; then
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
continue
fi
echo "Processing $lang_name ($lang_code)..."
# Export current state from POEditor
url=$(export_language "$lang_code")
curl -sSL "$url" -o poeditor_export.json
# Normalize both files for comparison
process_json "$file" > local_normalized.json
process_json poeditor_export.json > remote_normalized.json
# Compare normalized versions
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
echo " No differences, skipping"
else
echo " Differences found, updating POEditor..."
update_language "$lang_code" "$file"
upload_count=$((upload_count + 1))
fi
rm -f poeditor_export.json local_normalized.json remote_normalized.json
done
echo ""
echo "Done. Updated $upload_count translation(s) in POEditor."

View file

@ -1,32 +0,0 @@
name: POEditor export
on:
push:
branches:
- master
paths:
- 'resources/i18n/*.json'
jobs:
push-translations:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 2
- name: Detect changed translation files
id: changed
run: |
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
echo "Changed translation files: $CHANGED_FILES"
- name: Push translations to POEditor
if: ${{ steps.changed.outputs.files != '' }}
env:
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
run: |
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}

View file

@ -12,9 +12,8 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
- uses: dessant/lock-threads@v4.0.0
with:
process-only: 'issues, prs'
issue-inactive-days: 120
pr-inactive-days: 120
log-output: true
@ -28,7 +27,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@v10
- uses: actions/stale@v7
with:
operations-per-run: 999
days-before-issue-stale: 180

View file

@ -1,93 +0,0 @@
#!/bin/sh
set -e
I18N_DIR=resources/i18n
# Function to process JSON: remove empty attributes and sort
process_json() {
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
}
# Function to check differences between local and remote translations
check_lang_diff() {
filename=${I18N_DIR}/"$1".json
url=$(curl -s -X POST https://poeditor.com/api/ \
-d api_token="${POEDITOR_APIKEY}" \
-d action="export" \
-d id="${POEDITOR_PROJECTID}" \
-d language="$1" \
-d type="key_value_json" | jq -r .item)
if [ -z "$url" ]; then
echo "Failed to export $1"
return 1
fi
curl -sSL "$url" > poeditor.json
process_json "$filename" > "$filename".tmp
process_json poeditor.json > poeditor.tmp
diff=$(diff -u "$filename".tmp poeditor.tmp) || true
if [ -n "$diff" ]; then
echo "$diff"
mv poeditor.json "$filename"
fi
rm -f poeditor.json poeditor.tmp "$filename".tmp
}
# Function to get the list of languages
get_language_list() {
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/list \
-d api_token="${POEDITOR_APIKEY}" \
-d id="${POEDITOR_PROJECTID}")
echo $response
}
# Function to get the language name from the language code
get_language_name() {
lang_code="$1"
lang_list="$2"
lang_name=$(echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name")
if [ -z "$lang_name" ]; then
echo "Error: Language code '$lang_code' not found" >&2
return 1
fi
echo "$lang_name"
}
# Function to get the language code from the file path
get_lang_code() {
filepath="$1"
# Extract just the filename
filename=$(basename "$filepath")
# Remove the extension
lang_code="${filename%.*}"
echo "$lang_code"
}
lang_list=$(get_language_list)
# Check differences for each language
for file in ${I18N_DIR}/*.json; do
code=$(get_lang_code "$file")
lang=$(jq -r .languageName < "$file")
lang_name=$(get_language_name "$code" "$lang_list")
echo "Downloading $lang_name - $lang ($code)"
check_lang_diff "$code"
done
# List changed languages to stderr
languages=""
for file in $(git diff --name-only --exit-code | grep json); do
lang_code=$(get_lang_code "$file")
lang_name=$(get_language_name "$lang_code" "$lang_list")
languages="${languages}$(echo "$lang_name" | tr -d '\n'), "
done
echo "${languages%??}" 1>&2

View file

@ -8,26 +8,21 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v3
- name: Get updated translations
id: poeditor
env:
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
run: |
.github/workflows/update-translations.sh 2> title.tmp
title=$(cat title.tmp)
echo "::set-output name=title::$title"
rm title.tmp
./update-translations.sh
- name: Show changes, if any
run: |
git status --porcelain
git diff
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
uses: peter-evans/create-pull-request@v4
with:
token: ${{ secrets.PAT }}
author: "navidrome-bot <navidrome-bot@navidrome.org>"
commit-message: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor"
title: "fix(ui): update ${{ steps.poeditor.outputs.title }} translations from POEditor"
commit-message: Update translations
title: Update translations from POEditor
branch: update-translations

View file

@ -1,150 +0,0 @@
#!/usr/bin/env bash
#
# Validates DB migrations added by a pull request:
# 1. Ordering - an added migration must be NEWER than the latest migration
# already on the base branch. Goose applies migrations in
# timestamp order, so an older-timestamped migration would be
# silently skipped on databases already upgraded past it.
# 2. Uniqueness - no two migration files may share a timestamp.
# 3. Naming - files must match YYYYMMDDHHMMSS_lower_snake_name.(sql|go).
#
# On failure it prints a human-readable message and, when running in GitHub
# Actions, emits an error annotation bound to the offending file so the message
# also renders inline in the PR "Files changed" tab.
#
# Compares HEAD against $BASE_REF (default origin/master). Requires full history
# (fetch-depth: 0 in CI).
# -e is intentionally omitted: the script accumulates violations into $status
# and must not exit on the first non-zero command (grep no-match, a false [[ ]]
# in an if, `is_migration || continue`).
set -uo pipefail
export LC_ALL=C
MIGRATIONS_DIR="db/migrations"
BASE_REF="${BASE_REF:-origin/master}"
NAME_RE='^[0-9]{14}_[a-z0-9_]+\.(sql|go)$'
status=0
# Log a message to stderr and mark the run as failed.
fail() {
printf '%s\n' "$1" >&2
status=1
}
# Emit a GitHub Actions error annotation bound to a file, so the message renders
# inline on the offending migration in the PR "Files changed" tab. No-op outside
# CI. `%`, newline and CR are encoded as required by the workflow-command syntax
# (the `%` replacement must run first so the encodings we add aren't re-escaped).
annotate() { # $1=file $2=message
[ "${GITHUB_ACTIONS:-}" = "true" ] || return 0
local msg="$2"
msg="${msg//'%'/%25}"
msg="${msg//$'\n'/%0A}"
msg="${msg//$'\r'/%0D}"
printf '::error file=%s,line=1::%s\n' "$1" "$msg"
}
# Report a migration problem: log it, annotate the offending file, mark failed.
report() { # $1=file $2=message
fail "$2"
printf '\n' >&2
annotate "$1" "$2"
}
human_ts() {
local t="$1"
printf '%s-%s-%s %s:%s:%s' "${t:0:4}" "${t:4:2}" "${t:6:2}" "${t:8:2}" "${t:10:2}" "${t:12:2}"
}
is_migration() { # $1=basename -> 0 if a .sql/.go file with a 14-digit prefix
local b="$1"
case "$b" in
*.sql | *.go) ;;
*) return 1 ;;
esac
[[ "${b%%_*}" =~ ^[0-9]{14}$ ]]
}
if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null; then
printf '❌ Cannot resolve base ref "%s". In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2
exit 1
fi
# --- Newest timestamp already on the base branch ---
base_max=""
base_max_file=""
while IFS= read -r f; do
[ -z "$f" ] && continue
b="$(basename "$f")"
is_migration "$b" || continue
ts="${b%%_*}"
if [[ "$ts" > "$base_max" ]]; then
base_max="$ts"
base_max_file="$f"
fi
done < <(git ls-tree -r --name-only "$BASE_REF" -- "$MIGRATIONS_DIR" 2>/dev/null)
# --- Ordering + naming on files added by this PR ---
while IFS= read -r f; do
[ -z "$f" ] && continue
b="$(basename "$f")"
case "$b" in
*.sql) ;; # any .sql in this dir must be a migration
*.go) [[ "$b" == [0-9]* ]] || continue ;; # non-timestamped .go = helper (e.g. migration.go), skip
*) continue ;;
esac
if [ "${f%/*}" != "$MIGRATIONS_DIR" ]; then
report "$f" "❌ Migration file in a subdirectory: $f
Migrations must live directly in $MIGRATIONS_DIR/ — only $MIGRATIONS_DIR/*.sql (and
top-level .go migrations) are embedded, so a nested file would be SILENTLY SKIPPED.
Move it to $MIGRATIONS_DIR/$b."
continue
fi
if ! [[ "$b" =~ $NAME_RE ]]; then
report "$f" "❌ Malformed migration filename: $f
Expected YYYYMMDDHHMMSS_lower_snake_name.(sql|go); the name segment must be lowercase.
Regenerate with: make migration-sql name=<description> (or make migration-go name=<description>)"
continue
fi
ts="${b%%_*}"
if [[ -n "$base_max" ]] && ! [[ "$ts" > "$base_max" ]]; then
report "$f" "❌ Migration ordering error: $f ($(human_ts "$ts"))
is older than (or equal to) the newest migration already on ${BASE_REF#origin/}:
$base_max_file ($(human_ts "$base_max"))
Goose applies migrations in timestamp order, so databases already upgraded
past that point would SILENTLY SKIP your migration.
Fix: regenerate it with a current timestamp:
make migration-sql name=<description> (or make migration-go name=<description>)
then move your SQL/Go body into the new file and delete the old one."
fi
done < <(git diff --diff-filter=A --name-only "$BASE_REF"...HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)
# --- Duplicate timestamps across the merged set (HEAD) ---
all_migs="$(git ls-tree -r --name-only HEAD -- "$MIGRATIONS_DIR" 2>/dev/null)"
dups="$(printf '%s\n' "$all_migs" | while IFS= read -r f; do
b="$(basename "$f")"
is_migration "$b" || continue
printf '%s\n' "${b%%_*}"
done | sort | uniq -d)"
if [ -n "$dups" ]; then
while IFS= read -r ts; do
[ -z "$ts" ] && continue
colliding="$(printf '%s\n' "$all_migs" | grep "/${ts}_" || true)"
printf '❌ Duplicate migration timestamp %s used by multiple files:\n' "$ts" >&2
while IFS= read -r cf; do
[ -z "$cf" ] && continue
printf ' %s\n' "$cf" >&2
annotate "$cf" "Duplicate migration timestamp $ts — shared by another migration. Timestamps must be unique; regenerate one with make migration-*."
done <<< "$colliding"
printf ' Every migration needs a unique timestamp. Regenerate one with make migration-*.\n' >&2
status=1
done <<< "$dups"
fi
if [ "$status" -eq 0 ]; then
echo "✅ DB migrations OK (ordering, uniqueness, naming)."
fi
exit "$status"

View file

@ -1,236 +0,0 @@
#!/bin/bash
# validate-translations.sh
#
# This script validates the structure of JSON translation files by comparing them
# against the reference English translation file (ui/src/i18n/en.json).
#
# The script performs the following validations:
# 1. JSON syntax validation using jq
# 2. Structural validation - ensures all keys from English file are present
# 3. Reports missing keys (translation incomplete)
# 4. Reports extra keys (keys not in English reference, possibly deprecated)
# 5. Emits GitHub Actions annotations for CI/CD integration
#
# Usage:
# ./validate-translations.sh
#
# Environment Variables:
# EN_FILE - Path to reference English file (default: ui/src/i18n/en.json)
# TRANSLATION_DIR - Directory containing translation files (default: resources/i18n)
#
# Exit codes:
# 0 - All translations are valid
# 1 - One or more translations have structural issues
#
# GitHub Actions Integration:
# The script outputs GitHub Actions annotations using ::error and ::warning
# format that will be displayed in PR checks and workflow summaries.
# Script to validate JSON translation files structure against en.json
set -e
# Path to the reference English translation file
EN_FILE="${EN_FILE:-ui/src/i18n/en.json}"
TRANSLATION_DIR="${TRANSLATION_DIR:-resources/i18n}"
VERBOSE=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case "$1" in
-v|--verbose)
VERBOSE=true
shift
;;
-h|--help)
echo "Usage: $0 [options]"
echo ""
echo "Validates JSON translation files structure against English reference file."
echo ""
echo "Options:"
echo " -h, --help Show this help message"
echo " -v, --verbose Show detailed output (default: only show errors)"
echo ""
echo "Environment Variables:"
echo " EN_FILE Path to reference English file (default: ui/src/i18n/en.json)"
echo " TRANSLATION_DIR Directory with translation files (default: resources/i18n)"
echo ""
echo "Examples:"
echo " $0 # Validate all translation files (quiet mode)"
echo " $0 -v # Validate with detailed output"
echo " EN_FILE=custom/en.json $0 # Use custom reference file"
echo " TRANSLATION_DIR=custom/i18n $0 # Use custom translations directory"
exit 0
;;
*)
echo "Unknown option: $1" >&2
echo "Use --help for usage information" >&2
exit 1
;;
esac
done
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
if [[ "$VERBOSE" == "true" ]]; then
echo "Validating translation files structure against ${EN_FILE}..."
fi
# Check if English reference file exists
if [[ ! -f "$EN_FILE" ]]; then
echo "::error::Reference file $EN_FILE not found"
exit 1
fi
# Function to extract all JSON keys from a file, creating a flat list of dot-separated paths
extract_keys() {
local file="$1"
jq -r 'paths(scalars) as $p | $p | join(".")' "$file" 2>/dev/null | sort
}
# Function to extract all non-empty string keys (to identify structural issues)
extract_structure_keys() {
local file="$1"
# Get only keys where values are not empty strings
jq -r 'paths(scalars) as $p | select(getpath($p) != "") | $p | join(".")' "$file" 2>/dev/null | sort
}
# Function to validate a single translation file
validate_translation() {
local translation_file="$1"
local filename=$(basename "$translation_file")
local has_errors=false
local verbose=${2:-false}
if [[ "$verbose" == "true" ]]; then
echo "Validating $filename..."
fi
# First validate JSON syntax
if ! jq empty "$translation_file" 2>/dev/null; then
echo "::error file=$translation_file::Invalid JSON syntax"
echo -e "${RED}✗ $filename has invalid JSON syntax${NC}"
return 1
fi
# Extract all keys from both files (for statistics)
local en_keys_file=$(mktemp)
local translation_keys_file=$(mktemp)
extract_keys "$EN_FILE" > "$en_keys_file"
extract_keys "$translation_file" > "$translation_keys_file"
# Extract only non-empty structure keys (to validate structural issues)
local en_structure_file=$(mktemp)
local translation_structure_file=$(mktemp)
extract_structure_keys "$EN_FILE" > "$en_structure_file"
extract_structure_keys "$translation_file" > "$translation_structure_file"
# Find structural issues: keys in translation not in English (misplaced)
local extra_keys=$(comm -13 "$en_keys_file" "$translation_keys_file")
# Find missing keys (for statistics only)
local missing_keys=$(comm -23 "$en_keys_file" "$translation_keys_file")
# Count keys for statistics
local total_en_keys=$(wc -l < "$en_keys_file")
local total_translation_keys=$(wc -l < "$translation_keys_file")
local missing_count=0
local extra_count=0
if [[ -n "$missing_keys" ]]; then
missing_count=$(echo "$missing_keys" | grep -c '^' || echo 0)
fi
if [[ -n "$extra_keys" ]]; then
extra_count=$(echo "$extra_keys" | grep -c '^' || echo 0)
has_errors=true
fi
# Report extra/misplaced keys (these are structural issues)
if [[ -n "$extra_keys" ]]; then
if [[ "$verbose" == "true" ]]; then
echo -e "${YELLOW}Misplaced keys in $filename ($extra_count):${NC}"
fi
while IFS= read -r key; do
# Try to find the line number
line=$(grep -n "\"$(echo "$key" | sed 's/.*\.//')" "$translation_file" | head -1 | cut -d: -f1)
line=${line:-1} # Default to line 1 if not found
echo "::error file=$translation_file,line=$line::Misplaced key: $key"
if [[ "$verbose" == "true" ]]; then
echo " + $key (line ~$line)"
fi
done <<< "$extra_keys"
fi
# Clean up temp files
rm -f "$en_keys_file" "$translation_keys_file" "$en_structure_file" "$translation_structure_file"
# Print statistics
if [[ "$verbose" == "true" ]]; then
echo " Keys: $total_translation_keys/$total_en_keys (Missing: $missing_count, Extra/Misplaced: $extra_count)"
if [[ "$has_errors" == "true" ]]; then
echo -e "${RED}✗ $filename has structural issues${NC}"
else
echo -e "${GREEN}✓ $filename structure is valid${NC}"
fi
elif [[ "$has_errors" == "true" ]]; then
echo -e "${RED}✗ $filename has structural issues (Extra/Misplaced: $extra_count)${NC}"
fi
return $([[ "$has_errors" == "true" ]] && echo 1 || echo 0)
}
# Main validation loop
validation_failed=false
total_files=0
failed_files=0
valid_files=0
for translation_file in "$TRANSLATION_DIR"/*.json; do
if [[ -f "$translation_file" ]]; then
total_files=$((total_files + 1))
if ! validate_translation "$translation_file" "$VERBOSE"; then
validation_failed=true
failed_files=$((failed_files + 1))
else
valid_files=$((valid_files + 1))
fi
if [[ "$VERBOSE" == "true" ]]; then
echo "" # Add spacing between files
fi
fi
done
# Summary
if [[ "$VERBOSE" == "true" ]]; then
echo "========================================="
echo "Translation Validation Summary:"
echo " Total files: $total_files"
echo " Valid files: $valid_files"
echo " Files with structural issues: $failed_files"
echo "========================================="
fi
if [[ "$validation_failed" == "true" ]]; then
if [[ "$VERBOSE" == "true" ]]; then
echo -e "${RED}Translation validation failed - $failed_files file(s) have structural issues${NC}"
else
echo -e "${RED}Translation validation failed - $failed_files/$total_files file(s) have structural issues${NC}"
fi
exit 1
elif [[ "$VERBOSE" == "true" ]]; then
echo -e "${GREEN}All translation files are structurally valid${NC}"
fi
exit 0

34
.gitignore vendored
View file

@ -5,46 +5,24 @@
/navidrome
/iTunes*.xml
/tmp
/bin
data/*
vendor/*/
wiki
TODO.md
var
navidrome.toml
!release/linux/navidrome.toml
master.zip
testDB
navidrome.db
cache/*
*.swp
coverage.out
embedded_gen.go
dist
music
music.old
*.db*
navidrome.db-shm
navidrome.db-wal
tags
.gitinfo
docker-compose.yml
!contrib/docker-compose.yml
binaries
navidrome-*
/ndpgen
AGENTS.md
.github/prompts
.github/instructions
.github/git-commit-instructions.md
*.exe
*.test
*.wasm
*.ndp
openspec/
.agents
go.work*
.worktrees/
.playwright-mcp/
# Temp benchmark files
zz_*_test.go
# wazero compilation cache for the plugins test suite
/plugins/testdata/.wazero-cache/
/plugins/testdata/*.stage/
/api/openapi.yaml

View file

@ -1,87 +1,35 @@
version: "2"
run:
build-tags:
- netgo
- sqlite_fts5
go: "1.19"
linters:
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- containedctx
- copyloopvar
- dogsled
- durationcheck
- errcheck
- errorlint
- forbidigo
- gocritic
- exportloopref
- gocyclo
- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nakedret
- nilerr
- rowserrcheck
- unconvert
- whitespace
disable:
- staticcheck
settings:
errcheck:
exclude-functions:
- (*github.com/zeebo/xxh3.Hasher).Write
gocritic:
disable-all: true
enabled-checks:
- deprecatedComment
gosec:
excludes:
- G501
- G401
- G505
- G115
forbidigo:
forbid:
- pattern: 'tx\.Exec$'
msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context"
- pattern: 'tx\.Query$'
msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context"
- pattern: 'tx\.QueryRow$'
msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context"
govet:
enable:
- nilness
exclusions:
rules:
- linters:
- gosec
path: _test\.go
text: "G703"
- path: _test\.go
linters:
- containedctx
- path-except: 'db/migrations/'
linters:
- forbidigo
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- node_modules
- _gen\.go$
- .worktrees
formatters:
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
- node_modules
- typecheck
- unconvert
- unused
- whitespace
issues:
exclude-rules:
- linters:
- gosec
text: "(G501|G401|G505):"

139
.goreleaser.yml Normal file
View file

@ -0,0 +1,139 @@
# GoReleaser config
project_name: navidrome
builds:
- id: navidrome_linux_amd64
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static -lz'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_386
env:
- CGO_ENABLED=1
- PKG_CONFIG_PATH=/i386/lib/pkgconfig
goos:
- linux
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm
env:
- CGO_ENABLED=1
- CC=arm-linux-gnueabi-gcc
- CXX=arm-linux-gnueabi-g++
- PKG_CONFIG_PATH=/arm/lib/pkgconfig
goos:
- linux
goarch:
- arm
goarm:
- "5"
- "6"
- "7"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
- CXX=aarch64-linux-gnu-g++
- PKG_CONFIG_PATH=/arm64/lib/pkgconfig
goos:
- linux
goarch:
- arm64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_386
env:
- CGO_ENABLED=1
- CC=i686-w64-mingw32-gcc
- CXX=i686-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw32/lib/pkgconfig
goos:
- windows
goarch:
- "386"
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_windows_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
- PKG_CONFIG_PATH=/mingw64/lib/pkgconfig
goos:
- windows
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- "-extldflags '-static'"
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
- id: navidrome_darwin_amd64
env:
- CGO_ENABLED=1
- CC=o64-clang
- CXX=o64-clang++
- PKG_CONFIG_PATH=/darwin/lib/pkgconfig
goos:
- darwin
goarch:
- amd64
flags:
- -tags=netgo
ldflags:
- -s -w -X github.com/navidrome/navidrome/consts.gitSha={{.ShortCommit}} -X github.com/navidrome/navidrome/consts.gitTag={{.Version}}
archives:
- format_overrides:
- goos: windows
format: zip
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
checksum:
name_template: "{{ .ProjectName }}_checksums.txt"
snapshot:
name_template: "{{ .Tag }}-SNAPSHOT"
release:
draft: true
changelog:
# sort: asc
filters:
exclude:
- "^docs:"

2
.nvmrc
View file

@ -1 +1 @@
v24
v18

View file

@ -1,44 +0,0 @@
# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov
# The 30s default is not enough: scanning this repo's artifacts for the baseline
# eats most of it, leaving none for the report upload.
timeout: 5m
coverage:
# A single pre-merged profile: octocov reports statements for one path, but
# switches to line counting when it merges several itself.
paths:
- coverage.out
# Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated.
# Both patterns need the '**/' prefix: the comment workflow has no source tree,
# so octocov cannot shorten the profile's import paths to repo-relative ones.
exclude:
- '**/tests/**'
- '**/*_gen.go'
codeToTestRatio:
# Needs the pull request's own source, which the comment workflow must not
# check out: it holds a write token.
if: env.COVERAGE_COMMENT != 'true'
code:
- '**/*.go'
- '!**/*_test.go'
- '!**/*_gen.go'
test:
- '**/*_test.go'
testExecutionTime:
if: true
steps:
- Test with coverage
- Test plugins
diff:
datastores:
- artifact://${GITHUB_REPOSITORY}
comment:
# Only the 'Report coverage on PR' workflow sets this: a pull_request run from
# a fork gets a read-only token, so commenting from here 403s.
if: env.COVERAGE_COMMENT == 'true'
updatePrevious: true
summary:
if: true
report:
if: is_default_branch
datastores:
- artifact://${GITHUB_REPOSITORY}

View file

@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
### Commit Conventions
Each commit message must adhere to the following format:
```
<type>(scope): <description>
<type>(scope): <description> - <issue number>
[optional body]
```
@ -48,15 +48,14 @@ This improves the readability of the messages
It can be one of the following:
1. **feat**: Addition of a new feature
2. **fix**: Bug fix
3. **sec**: Fixing security issues
4. **docs**: Documentation Changes
5. **style**: Changes to styling
6. **refactor**: Refactoring of code
7. **perf**: Code that affects performance
8. **test**: Updating or improving the current tests
9. **build**: Changes to Build process
10. **revert**: Reverting to a previous commit
11. **chore** : updating grunt tasks etc
3. **docs**: Documentation Changes
4. **style**: Changes to styling
5. **refactor**: Refactoring of code
6. **perf**: Code that affects performance
7. **test**: Updating or improving the current tests
8. **build**: Changes to Build process
9. **revert**: Reverting to a previous commit
10. **chore** : updating grunt tasks etc
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section

View file

@ -1,217 +0,0 @@
FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcross
########################################################################################################################
### Build xx (original image: tonistiigi/xx)
FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build
# v1.9.0
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
RUN apk add -U --no-cache git
RUN git clone https://github.com/tonistiigi/xx && \
cd xx && \
git checkout ${XX_VERSION} && \
mkdir -p /out && \
cp src/xx-* /out/
RUN cd /out && \
ln -s xx-cc /out/xx-clang && \
ln -s xx-cc /out/xx-clang++ && \
ln -s xx-cc /out/xx-c++ && \
ln -s xx-apt /out/xx-apt-get
# xx mimics the original tonistiigi/xx image
FROM scratch AS xx
COPY --from=xx-build /out/ /usr/bin/
########################################################################################################################
### Build Navidrome UI
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
WORKDIR /app
# Install node dependencies
COPY ui/package.json ui/package-lock.json ./
COPY ui/bin/ ./bin/
RUN npm ci
# Build bundle
COPY ui/ ./
RUN npm run build -- --outDir=/build
FROM scratch AS ui-bundle
COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine
COPY --from=xx / /
ARG TARGETPLATFORM
RUN apk add --no-cache clang lld file git
RUN xx-apk add --no-cache gcc musl-dev zlib-dev
RUN xx-verify --setup
WORKDIR /workspace
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
ARG GIT_SHA
ARG GIT_TAG
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -e
xx-go --wrap
export CGO_ENABLED=1
BUILD_TAGS=$(./release/build-tags.sh)
# -latomic is required on 32-bit arm (arm/v6, arm/v7) so SQLite's 64-bit atomics resolve.
go build -tags="${BUILD_TAGS}" -ldflags="-w -s \
-linkmode=external -extldflags '-latomic' \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome .
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
./release/verify-binary.sh /out/navidrome
# Fail the build if the binary is accidentally statically linked: dlopen (and
# therefore native libwebp detection) only works with a dynamic interpreter.
file /out/navidrome | grep -q "dynamically linked" || { echo "ERROR: /out/navidrome is not dynamically linked"; file /out/navidrome; exit 1; }
EOT
########################################################################################################################
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
FROM --platform=$BUILDPLATFORM base AS build
# Install build dependencies for the target platform
ARG TARGETPLATFORM
RUN xx-apt install -y binutils gcc g++ libc6-dev zlib1g-dev
RUN xx-verify --setup
RUN --mount=type=bind,source=. \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod \
go mod download
ARG GIT_SHA
ARG GIT_TAG
RUN --mount=type=bind,source=. \
--mount=from=ui,source=/build,target=./ui/build,ro \
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
--mount=type=cache,target=/root/.cache \
--mount=type=cache,target=/go/pkg/mod <<EOT
set -e
# Setup CGO cross-compilation environment
xx-go --wrap
export CGO_ENABLED=1
cat "$(go env GOENV)" 2>/dev/null || true
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
# So let's use gcc for everything except Darwin.
if [ "$(xx-info os)" != "darwin" ]; then
export CC=$(xx-info)-gcc
export CXX=$(xx-info)-g++
export LD_EXTRA="-extldflags '-static -latomic'"
fi
# GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers
# (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch
# range, making static arm binaries jump to garbage inside glibc's ifunc
# resolution and crash before main() (issue #5738). Link 32-bit arm with LLD,
# which emits correct addends.
if [ "$(xx-info arch)" = "arm" ]; then
export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'"
fi
if [ "$(xx-info os)" = "windows" ]; then
export EXT=".exe"
fi
BUILD_TAGS=$(./release/build-tags.sh)
go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
-o /out/navidrome${EXT} .
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
./release/verify-binary.sh /out/navidrome*
EOT
# Verify if the binary was built for the correct platform and it is statically linked
RUN xx-verify --static /out/navidrome*
FROM scratch AS binary
COPY --from=build /out /
########################################################################################################################
### Build no-op stubs for mpv's video-output libraries
# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio.
# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls.
FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs
COPY --from=xx / /
RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm
ARG TARGETPLATFORM
RUN xx-apk add --no-cache musl-dev
RUN <<EOT
set -e
mkdir -p /out
for so in libEGL.so.1 libgbm.so.1; do
readelf -sW /usr/lib/$so \
| awk '$5 == "GLOBAL" && $7 != "UND" { print $8 }' \
| sed 's/@.*//' \
| grep -vE '^(_init|_fini|_edata|_end|__bss_start|_GLOBAL_OFFSET_TABLE_)$' \
| sort -u \
| awk '{ print "void " $1 "(void) {}" }' > /tmp/stub.c
test -s /tmp/stub.c
xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c
xx-verify /out/$so
done
EOT
########################################################################################################################
### Build Final Image
FROM alpine:3.22 AS final
LABEL maintainer="deluan@navidrome.org"
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
# Install runtime dependencies
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
# otherwise the deleted bytes still ship in the image.
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
for lib in libwebp libwebpdemux libwebpmux; do \
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
done && \
rm -rf /usr/lib/gallium-pipe /usr/lib/dri \
/usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \
/usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so*
COPY --from=mpv-stubs /out/ /usr/lib/
RUN mpv --no-video --ao=null --version > /dev/null
# Copy navidrome binary (musl build for Docker, enables native libwebp)
COPY --from=build-alpine /out/navidrome /app/
VOLUME ["/data", "/music"]
ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
WORKDIR /app
ENV PATH="/app:${PATH}"
ENTRYPOINT ["/app/navidrome"]

305
Makefile
View file

@ -1,34 +1,17 @@
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
NODE_VERSION=$(shell cat .nvmrc)
comma:=,
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
# Set global environment variables, required for most targets
export ND_ENABLEINSIGHTSCOLLECTOR=false
ifneq ("$(wildcard .git/HEAD)","")
GIT_SHA=$(shell git rev-parse --short HEAD)
GIT_TAG=$(shell git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0)-SNAPSHOT
GIT_TAG=$(shell git describe --tags `git rev-list --tags --max-count=1`)
else
GIT_SHA=source_archive
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))
endif
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
DOCKER_TAG ?= deluan/navidrome:develop
CI_RELEASER_VERSION=1.20.3-1 ## https://github.com/navidrome/ci-goreleaser
GOLANGCI_LINT_VERSION ?= v2.14.0
VACUUM_VERSION ?= v0.30.6
OAPI_CODEGEN_VERSION ?= v2.8.0
OASDIFF_VERSION ?= v1.32.1
API_DIFF_BASE ?= origin/master
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First Install dependencies and prepare development environment
setup: check_env download-deps setup-git ##@1_Run_First Install dependencies and prepare development environment
@echo Downloading Node dependencies...
@(cd ./ui && npm ci)
.PHONY: setup
@ -37,145 +20,60 @@ dev: check_env ##@Development Start Navidrome in development mode, with hot-re
npx foreman -j Procfile.dev -p 4533 start
.PHONY: dev
server: check_go_env buildjs ##@Development Start the backend in development mode
go tool reflex -d none -c reflex.conf
server: check_go_env ##@Development Start the backend in development mode
@go run github.com/cespare/reflex@latest -d none -c reflex.conf
.PHONY: server
stop: ##@Development Stop development servers (UI and backend)
@echo "Stopping development servers..."
@-pkill -f "vite"
@-pkill -f "go tool reflex.*reflex.conf"
@-pkill -f "go run.*netgo"
@echo "Development servers stopped."
.PHONY: stop
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
go run github.com/onsi/ginkgo/v2/ginkgo@latest watch -notify ./...
.PHONY: watch
PKG ?= ./...
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
go test -tags $(GO_BUILD_TAGS) $(PKG)
test: ##@Development Run Go tests
go test -race -shuffle=on ./...
.PHONY: test
test-ndpgen: ##@Development Run tests for ndpgen plugin
cd plugins/cmd/ndpgen && go test ./......
.PHONY: test-ndpgen
testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
testall: test ##@Development Run Go and JS tests, and validate OpenAPI spec
@(cd ./ui && npm test -- --watchAll=false)
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
.PHONY: test-race
test-js: ##@Development Run JS tests
@(cd ./ui && npm run test)
.PHONY: test-js
test-i18n: ##@Development Validate all translations files
./.github/workflows/validate-translations.sh
.PHONY: test-i18n
install-golangci-lint: ##@Development Install golangci-lint if not present
@INSTALL=false; \
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
rm -f ./bin/golangci-lint; \
INSTALL=true; \
fi; \
else \
INSTALL=true; \
fi; \
if [ "$$INSTALL" = "true" ]; then \
echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."; \
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s $(GOLANGCI_LINT_VERSION); \
fi
.PHONY: install-golangci-lint
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
echo "Installing OpenAPI tools..."; \
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
rm -f bin/.api-tools-* && touch $$STAMP; \
fi
.PHONY: install-api-tools
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
.PHONY: api-lint
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
.PHONY: api-bundle
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
.PHONY: api-gen
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
if [ -z "$$BASE" ]; then \
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
BASE=$(API_DIFF_BASE); \
fi; \
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
else \
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
fi
.PHONY: api-diff
lint: install-golangci-lint ##@Development Lint Go code
PATH=./bin:$$PATH golangci-lint run --timeout 5m
lint: ##@Development Lint Go code
go run github.com/golangci/golangci-lint/cmd/golangci-lint@latest run -v --timeout 5m
.PHONY: lint
lintall: lint ##@Development Lint Go and JS code
lintapi: api/openapi.yaml ##@Development Lint OpenAPI spec
npx @redocly/cli lint api/openapi.yaml
.PHONY: lintapi
lintall: lint lintapi ##@Development Lint Go and JS code
@(cd ./ui && npm run check-formatting) || (echo "\n\nPlease run 'npm run prettier' to fix formatting issues." && exit 1)
@(cd ./ui && npm run lint)
.PHONY: lintall
format: ##@Development Format code
@(cd ./ui && npm run prettier)
@go tool goimports -w `find . -name '*.go' | grep -v _gen.go$$ | grep -v .pb.go$$`
@go mod tidy
.PHONY: format
wire: check_go_env ##@Development Update Dependency Injection
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
gen: check_go_env api ##@Development Update Generated Code (wire, mocks, openapi, etc)
go run github.com/google/wire/cmd/wire@latest ./...
.PHONY: wire
gen: check_go_env api-gen ##@Development Run go generate for code generation
go generate ./...
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
go mod tidy -C plugins/pdk/go
.PHONY: gen
api: check_go_env api/openapi.yaml
go generate ./server/api/...
.PHONY: api
spec_parts=$(shell find api -name '*.yml')
api/openapi.yaml: $(spec_parts)
@echo "Bundling OpenAPI spec..."
npx @redocly/cli bundle api/spec.yml -o api/openapi.yaml
snapshots: ##@Development Update (GoLang) Snapshot tests
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
UPDATE_SNAPSHOTS=true go run github.com/onsi/ginkgo/v2/ginkgo@latest ./server/subsonic/...
.PHONY: snapshots
migration-sql: ##@Development Create an empty SQL migration file
@if [ -z "${name}" ]; then echo "Usage: make migration-sql name=name_of_migration_file"; exit 1; fi
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name} sql
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name} sql
.PHONY: migration
migration-go: ##@Development Create an empty Go migration file
@if [ -z "${name}" ]; then echo "Usage: make migration-go name=name_of_migration_file"; exit 1; fi
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migrations create ${name}
go run github.com/pressly/goose/v3/cmd/goose@latest -dir db/migration create ${name}
.PHONY: migration
setup-dev: setup
@ -187,134 +85,53 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
@(cd .git/hooks && ln -sf ../../git/* .)
.PHONY: setup-git
build: check_go_env buildjs ##@Build Build the project
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
.PHONY: build
buildall: deprecated build
buildall: buildjs build ##@Build Build the project, both frontend and backend
.PHONY: buildall
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
.PHONY: debug-build
build: warning-noui-build check_go_env ##@Build Build only backend
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)-SNAPSHOT" -tags=netgo
.PHONY: build
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
buildjs: check_node_env ##@Build Build only frontend
@(cd ./ui && npm run build)
.PHONY: buildjs
docker-buildjs: ##@Build Build only frontend using Docker
docker build --output "./ui" --target ui-bundle .
.PHONY: docker-buildjs
all: warning-noui-build ##@Cross_Compilation Build binaries for all supported platforms. It does not build the frontend
docker run -t -v $(PWD):/workspace -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser release --rm-dist --skip-publish --snapshot
.PHONY: all
ui/build/index.html: $(UI_SRC_FILES)
@(cd ./ui && npm run build)
single: warning-noui-build ##@Cross_Compilation Build binaries for a single supported platforms. It does not build the frontend
@if [ -z "${GOOS}" -o -z "${GOARCH}" ]; then \
echo "Usage: GOOS=<os> GOARCH=<arch> make single"; \
echo "Options:"; \
grep -- "- id: navidrome_" .goreleaser.yml | sed 's/- id: navidrome_//g'; \
exit 1; \
fi
@echo "Building binaries for ${GOOS}/${GOARCH}"
docker run -t -v $(PWD):/workspace -e GOOS -e GOARCH -w /workspace deluan/ci-goreleaser:$(CI_RELEASER_VERSION) \
goreleaser build --rm-dist --snapshot --single-target --id navidrome_${GOOS}_${GOARCH}
.PHONY: single
docker-platforms: ##@Cross_Compilation List supported platforms
@echo "Supported platforms:"
@echo "$(SUPPORTED_PLATFORMS)" | tr ',' '\n' | sort | sed 's/^/ /'
@echo "\nUsage: make PLATFORMS=\"linux/amd64\" docker-build"
@echo " make IMAGE_PLATFORMS=\"linux/amd64\" docker-image"
.PHONY: docker-platforms
docker-build: ##@Cross_Compilation Cross-compile for any supported platform (check `make docker-platforms`)
docker buildx build \
--platform $(PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--output "./binaries" --target binary .
.PHONY: docker-build
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
docker buildx build \
--platform $(IMAGE_PLATFORMS) \
--build-arg GIT_TAG=${GIT_TAG} \
--build-arg GIT_SHA=${GIT_SHA} \
--tag $(DOCKER_TAG) .
.PHONY: docker-image
docker-msi: ##@Cross_Compilation Build MSI installer for Windows
make docker-build PLATFORMS=windows/386,windows/amd64
DOCKER_CLI_HINTS=false docker build -q -t navidrome-msi-builder -f release/wix/msitools.dockerfile .
@rm -rf binaries/msi
docker run -it --rm -v $(PWD):/workspace -v $(PWD)/binaries:/workspace/binaries -e GIT_TAG=${GIT_TAG} \
navidrome-msi-builder sh -c "release/wix/build_msi.sh /workspace 386 && release/wix/build_msi.sh /workspace amd64"
@du -h binaries/msi/*.msi
.PHONY: docker-msi
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
if [ -f navidrome.toml ]; then \
VOLUMES="$$VOLUMES -v $(PWD)/navidrome.toml:/data/navidrome.toml:ro"; \
MUSIC_FOLDER=$$(grep '^MusicFolder' navidrome.toml | head -n1 | sed 's/.*= *"//' | sed 's/".*//'); \
if [ -n "$$MUSIC_FOLDER" ] && [ -d "$$MUSIC_FOLDER" ]; then \
VOLUMES="$$VOLUMES -v $$MUSIC_FOLDER:/music:ro"; \
fi; \
fi; \
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
.PHONY: docker-run
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
goreleaser release -f release/goreleaser.yml --clean --skip=publish --snapshot
.PHONY: package
warning-noui-build:
@echo "WARNING: This command does not build the frontend, it uses the latest built with 'make buildjs'"
.PHONY: warning-noui-build
get-music: ##@Development Download some free music from Navidrome's demo instance
mkdir -p music
( cd music; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=2Y3qQA6zJC3ObbBrF9ZBoV" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=04HrSORpypcLGNUdQp37gn" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=5xcMPJdeEgNrGtnzYbzAqb" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=1jjQMAZrG3lUsJ0YH6ZRS0" > voodoocuts.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=ec2093ec4801402f1e17cc462195cdbb" > brock.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=b376eeb4652d2498aa2b25ba0696725e" > back_on_earth.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=e49c609b542fc51899ee8b53aa858cb4" > ugress.zip; \
curl "https://demo.navidrome.org/rest/download?u=demo&p=demo&f=json&v=1.8.0&c=dev_download&id=350bcab3a4c1d93869e39ce496464f03" > voodoocuts.zip; \
for file in *.zip; do unzip -n $${file}; done )
@echo "Done. Remember to set your MusicFolder to ./music"
.PHONY: get-music
##########################################
#### Worktrees
WORKTREES_DIR := .worktrees
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
@mkdir -p $(WORKTREES_DIR)
@echo "Creating worktree for branch '${name}'..."
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
git worktree add $(WORKTREES_DIR)/${name} ${name}
@if [ -n "${go}" ]; then \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
else \
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
fi
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
@echo " cd $(WORKTREES_DIR)/${name}"
.PHONY: wt
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
@echo "Removing worktree '${name}'..."
@git worktree remove --force $(WORKTREES_DIR)/${name}
@echo "Worktree '${name}' removed."
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
.PHONY: rm-wt
ls-wt: ##@Worktrees List all active git worktrees
@git worktree list
.PHONY: ls-wt
##########################################
#### Miscellaneous
clean:
@rm -rf ./binaries ./dist ./ui/build/*
@touch ./ui/build/.gitkeep
.PHONY: clean
release:
@if [[ ! "${V}" =~ ^[0-9]+\.[0-9]+\.[0-9]+.*$$ ]]; then echo "Usage: make release V=X.X.X"; exit 1; fi
go mod tidy
@ -354,10 +171,6 @@ check_node_env:
pre-push: lintall testall
.PHONY: pre-push
deprecated:
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
.PHONY: deprecated
.DEFAULT_GOAL := help
HELP_FUN = \

View file

@ -1,2 +1,2 @@
JS: sh -c "cd ./ui && npm start"
GO: go tool reflex -d none -c reflex.conf
GO: go run github.com/cespare/reflex@latest -d none -c reflex.conf

View file

@ -9,7 +9,6 @@
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md)
[![Gurubase](https://img.shields.io/badge/Gurubase-Ask%20Navidrome%20Guru-006BFF?style=flat-square)](https://gurubase.io/g/navidrome)
Navidrome is an open source web-based music collection server and streamer. It gives you freedom to listen to your
music collection from any browser or mobile device. It's like your personal Spotify!
@ -52,21 +51,11 @@ A share of the revenue helps fund the development of Navidrome at no additional
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
- Ready to use binaries for all major platforms, including **Raspberry Pi**
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
- Supports **lyrics** from sidecar .ttml, .yaml/.yml Lyricsfile, .elrc, .lrc, .srt, .txt files and embedded TTML, Enhanced LRC, LRC, SRT, and plain-text tags (via `lyricspriority`)
- **Themeable**, modern and responsive **Web interface** based on [Material UI](https://material-ui.com)
- **Compatible** with all Subsonic/Madsonic/Airsonic [clients](https://www.navidrome.org/docs/overview/#apps)
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
- Translated to **various languages**
## Translations
Navidrome uses [POEditor](https://poeditor.com/) for translations, and we are always looking
for [more contributors](https://www.navidrome.org/docs/developers/translations/)
<a href="https://poeditor.com/">
<img height="32" src="https://github.com/user-attachments/assets/c19b1d2b-01e1-4682-a007-12356c42147c">
</a>
## Documentation
All documentation can be found in the project's website: https://www.navidrome.org/docs.
Here are some useful direct links:

View file

@ -1,239 +0,0 @@
package deezer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
const authBaseURL = "https://auth.deezer.com"
// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200
// and no rate-limit headers, so the body code is the only signal.
const errCodeQuota = 4
type deezerError struct {
Type string `json:"type"`
Message string `json:"message"`
Code int `json:"code"`
}
func (e *deezerError) Error() string {
return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message)
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
type client struct {
httpDoer httpDoer
jwt jwtToken
}
func newClient(hc httpDoer) *client {
return &client{
httpDoer: hc,
}
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
params := url.Values{}
params.Add("q", name)
params.Add("order", "RANKING")
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", apiBaseURL+"/search/artist", nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results SearchArtistResults
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
if len(results.Data) == 0 {
return nil, agents.ErrNotFound
}
return results.Data, nil
}
func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
resp, err := c.httpDoer.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
// Checked before the status: a throttled request still answers 200, and decoding its body
// into a result type yields an empty one, which reads as "nothing found".
if err := parseBodyError(data); err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
}
return json.Unmarshal(data, response)
}
// parseBodyError returns the error Deezer reported in the body, or nil when it reported none.
func parseBodyError(data []byte) error {
var body errorResponse
// Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer.
_ = json.Unmarshal(data, &body)
switch {
case body.Error == nil:
return nil
case body.Error.Code == errCodeQuota:
return errors.Join(body.Error, agents.ErrRetryLater)
default:
return body.Error
}
}
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
var results RelatedArtists
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
params := url.Values{}
params.Add("limit", strconv.Itoa(limit))
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
var results TopTracks
err = c.makeRequest(req, &results)
if err != nil {
return nil, err
}
return results.Data, nil
}
const pipeAPIURL = "https://pipe.deezer.com/api"
var strictPolicy = bluemonday.StrictPolicy()
func (c *client) getArtistBio(ctx context.Context, artistID int, lang string) (string, error) {
jwt, err := c.getJWT(ctx)
if err != nil {
return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
}
query := map[string]any{
"operationName": "ArtistBio",
"variables": map[string]any{
"artistId": strconv.Itoa(artistID),
},
"query": `query ArtistBio($artistId: String!) {
artist(artistId: $artistId) {
bio {
full
}
}
}`,
}
body, err := json.Marshal(query)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept-Language", lang)
req.Header.Set("Authorization", "Bearer "+jwt)
log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", lang)
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type graphQLResponse struct {
Data struct {
Artist struct {
Bio struct {
Full string `json:"full"`
} `json:"bio"`
} `json:"artist"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
}
}
var result graphQLResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
}
if len(result.Errors) > 0 {
var errs []error
for m := range result.Errors {
errs = append(errs, errors.New(result.Errors[m].Message))
}
err := errors.Join(errs...)
return "", fmt.Errorf("deezer: GraphQL error: %w", err)
}
if result.Data.Artist.Bio.Full == "" {
return "", errors.New("deezer: biography not found")
}
return cleanBio(result.Data.Artist.Bio.Full), nil
}
func cleanBio(bio string) string {
bio = strings.ReplaceAll(bio, "</p>", "\n")
return strictPolicy.Sanitize(bio)
}

View file

@ -1,101 +0,0 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/navidrome/navidrome/log"
)
type jwtToken struct {
token string
expiresAt time.Time
mu sync.RWMutex
}
func (j *jwtToken) get() (string, bool) {
j.mu.RLock()
defer j.mu.RUnlock()
if time.Now().Before(j.expiresAt) {
return j.token, true
}
return "", false
}
func (j *jwtToken) set(token string, expiresIn time.Duration) {
j.mu.Lock()
defer j.mu.Unlock()
j.token = token
j.expiresAt = time.Now().Add(expiresIn)
}
func (c *client) getJWT(ctx context.Context) (string, error) {
// Check if we have a valid cached token
if token, valid := c.jwt.get(); valid {
return token, nil
}
// Fetch a new anonymous token
req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpDoer.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
type authResponse struct {
JWT string `json:"jwt"` //nolint:gosec
}
var result authResponse
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
}
if result.JWT == "" {
return "", errors.New("deezer: no JWT token in response")
}
// Parse JWT to get actual expiration time
token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
if err != nil {
return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
}
// Calculate TTL with a 1-minute buffer for clock skew and network delays
expiresAt, ok := token.Expiration()
if !ok || expiresAt.IsZero() {
return "", errors.New("deezer: JWT token has no expiration time")
}
ttl := time.Until(expiresAt) - 1*time.Minute
if ttl <= 0 {
return "", errors.New("deezer: JWT token already expired or expires too soon")
}
c.jwt.set(result.JWT, ttl)
log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
return result.JWT, nil
}

View file

@ -1,294 +0,0 @@
package deezer
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"sync"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("JWT Authentication", func() {
var httpClient *fakeHttpClient
var client *client
var ctx context.Context
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
ctx = context.Background()
})
Describe("getJWT", func() {
Context("with a valid JWT response", func() {
It("successfully fetches and caches a JWT token", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).To(Equal(testJWT))
})
It("returns the cached token on subsequent calls", func() {
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
// First call should fetch from API
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(testJWT))
Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
// Second call should return cached token without hitting API
httpClient.lastRequest = nil // Clear last request to verify no new request is made
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(testJWT))
Expect(httpClient.lastRequest).To(BeNil()) // No new request made
})
It("parses the JWT expiration time correctly", func() {
expectedExpiration := time.Now().Add(5 * time.Minute)
testToken, err := jwt.NewBuilder().
Expiration(expectedExpiration).
Build()
Expect(err).To(BeNil())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
// Verify the token is cached until close to expiration
// The cache should expire 1 minute before the JWT expires
expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
})
})
Context("with JWT tokens that expire soon", func() {
It("rejects tokens that expire in less than 1 minute", func() {
// Create a token that expires in 30 seconds (less than 1-minute buffer)
testJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("rejects already expired tokens", func() {
// Create a token that expired 1 minute ago
testJWT := createTestJWT(-1 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
It("accepts tokens that expire in more than 1 minute", func() {
// Create a token that expires in 2 minutes (just over the 1-minute buffer)
testJWT := createTestJWT(2 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
})
token, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token).ToNot(BeEmpty())
})
})
Context("with invalid responses", func() {
It("handles HTTP error responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
})
It("handles malformed JSON responses", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
})
It("handles responses with empty JWT field", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
})
It("handles invalid JWT tokens", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
})
_, err := client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
})
It("rejects JWT tokens without expiration", func() {
// Create a JWT without expiration claim
testToken, err := jwt.NewBuilder().
Claim("custom", "value").
Build()
Expect(err).To(BeNil())
// Verify token has no expiration
_, hasExp := testToken.Expiration()
Expect(hasExp).To(BeFalse())
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
Expect(err).To(BeNil())
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
})
_, err = client.getJWT(ctx)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
})
})
Context("token caching behavior", func() {
It("fetches a new token when the cached token expires", func() {
// First token expires in 5 minutes
firstJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
})
token1, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token1).To(Equal(firstJWT))
// Manually expire the cached token
client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
// Second token with different expiration (10 minutes)
secondJWT := createTestJWT(10 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
})
token2, err := client.getJWT(ctx)
Expect(err).To(BeNil())
Expect(token2).To(Equal(secondJWT))
Expect(token2).ToNot(Equal(token1))
})
})
})
Describe("jwtToken cache", func() {
var cache *jwtToken
BeforeEach(func() {
cache = &jwtToken{}
})
It("returns false for expired tokens", func() {
cache.set("test-token", -1*time.Second) // Already expired
token, valid := cache.get()
Expect(valid).To(BeFalse())
Expect(token).To(BeEmpty())
})
It("returns true for valid tokens", func() {
cache.set("test-token", 4*time.Minute)
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(Equal("test-token"))
})
It("is thread-safe for concurrent access", func() {
wg := sync.WaitGroup{}
// Writer goroutine
wg.Go(func() {
for i := range 100 {
cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
time.Sleep(1 * time.Millisecond)
}
})
// Reader goroutine
wg.Go(func() {
for range 100 {
cache.get()
time.Sleep(1 * time.Millisecond)
}
})
// Wait for both goroutines to complete
wg.Wait()
// Verify final state is valid
token, valid := cache.get()
Expect(valid).To(BeTrue())
Expect(token).To(HavePrefix("token-"))
})
})
})
// createTestJWT creates a valid JWT token for testing purposes
func createTestJWT(expiresIn time.Duration) string {
token, err := jwt.NewBuilder().
Expiration(time.Now().Add(expiresIn)).
Build()
if err != nil {
panic(fmt.Sprintf("failed to create test JWT: %v", err))
}
signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
if err != nil {
panic(fmt.Sprintf("failed to sign test JWT: %v", err))
}
return string(signed)
}

View file

@ -1,242 +0,0 @@
package deezer
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/core/agents"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *fakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &fakeHttpClient{}
client = newClient(httpClient)
})
Describe("ArtistImages", func() {
It("returns artist images from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.search.artist.json")
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(17))
Expect(artists[0].Name).To(Equal("Michael Jackson"))
Expect(artists[0].PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
})
It("fails if artist was not found", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(MatchError(agents.ErrNotFound))
})
// Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal.
It("reports an exhausted quota as a retryable error, not as a missing artist", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(agents.ErrNotFound),
"a throttled lookup would otherwise settle the artist as having no image")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
Expect(err.Error()).To(ContainSubstring("Quota limit exceeded"))
})
It("reports a non-quota body error as a plain error", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(
`{"error":{"type":"Exception","message":"Invalid query","code":100}}`)),
})
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(agents.ErrNotFound))
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(),
"only a throttle asks the caller to come back later")
})
})
Describe("TopTracks", func() {
It("returns top tracks with artist and album info from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/artist/27/top", http.Response{Body: f, StatusCode: 200})
tracks, err := client.getTopTracks(GinkgoT().Context(), 27, 5)
Expect(err).To(BeNil())
Expect(tracks).To(HaveLen(5))
// Verify first track has all expected fields
Expect(tracks[0].Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(tracks[0].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[0].Album.Title).To(Equal("Random Access Memories"))
// Verify second track
Expect(tracks[1].Title).To(Equal("One More Time"))
Expect(tracks[1].Artist.Name).To(Equal("Daft Punk"))
Expect(tracks[1].Album.Title).To(Equal("Discovery"))
})
})
Describe("ArtistBio", func() {
BeforeEach(func() {
// Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
testJWT := createTestJWT(5 * time.Minute)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
})
})
It("returns artist bio from a successful request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
bio, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(bio).ToNot(ContainSubstring("<p>"))
Expect(bio).ToNot(ContainSubstring("</p>"))
})
It("uses the provided language", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27, "fr")
Expect(err).To(BeNil())
Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
})
It("includes the JWT token in the request", func() {
f, err := os.Open("tests/fixtures/deezer.artist.bio.en.json")
Expect(err).To(BeNil())
httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
_, err = client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(BeNil())
// Verify that the Authorization header has the Bearer token format
authHeader := httpClient.lastRequest.Header.Get("Authorization")
Expect(authHeader).To(HavePrefix("Bearer "))
Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
})
It("handles GraphQL errors", func() {
errorResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
},
"errors": [
{
"message": "Artist not found"
},
{
"message": "Invalid artist ID"
}
]
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 999, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("GraphQL error"))
Expect(err.Error()).To(ContainSubstring("Artist not found"))
Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
})
It("handles empty biography", func() {
emptyBioResponse := `{
"data": {
"artist": {
"bio": {
"full": ""
}
}
}
}`
httpClient.mock("https://pipe.deezer.com/api", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(MatchError("deezer: biography not found"))
})
It("handles JWT token fetch failure", func() {
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
})
It("handles JWT token that expires too soon", func() {
// Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
expiredJWT := createTestJWT(30 * time.Second)
httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
})
_, err := client.getArtistBio(GinkgoT().Context(), 27, "en")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
})
})
})
type fakeHttpClient struct {
responses map[string]*http.Response
lastRequest *http.Request
}
func (c *fakeHttpClient) mock(url string, response http.Response) {
if c.responses == nil {
c.responses = make(map[string]*http.Response)
}
c.responses[url] = &response
}
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
c.lastRequest = req
u := req.URL
u.RawQuery = ""
if resp, ok := c.responses[u.String()]; ok {
return resp, nil
}
panic("URL not mocked: " + u.String())
}

View file

@ -1,199 +0,0 @@
package deezer
import (
"cmp"
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/slice"
)
const deezerAgentName = "deezer"
const deezerApiPictureXlSize = 1000
const deezerApiPictureBigSize = 500
const deezerApiPictureMediumSize = 250
const deezerApiPictureSmallSize = 56
const deezerArtistSearchLimit = 50
type deezerAgent struct {
dataStore model.DataStore
client *client
languages []string
}
func deezerConstructor(dataStore model.DataStore) agents.Interface {
agent := &deezerAgent{
dataStore: dataStore,
languages: conf.Server.Deezer.Languages,
}
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
agent.client = newClient(cachedHttpClient)
return agent
}
func (s *deezerAgent) AgentName() string {
return deezerAgentName
}
func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([]agents.ExternalImage, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
if errors.Is(err, agents.ErrNotFound) {
log.Warn(ctx, "Artist not found in deezer", "artist", name)
} else {
log.Error(ctx, "Error calling deezer", "artist", name, err)
}
return nil, err
}
var res []agents.ExternalImage
possibleImages := []struct {
URL string
Size int
}{
{artist.PictureXl, deezerApiPictureXlSize},
{artist.PictureBig, deezerApiPictureBigSize},
{artist.PictureMedium, deezerApiPictureMediumSize},
{artist.PictureSmall, deezerApiPictureSmallSize},
}
for _, imgData := range possibleImages {
if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) {
res = append(res, agents.ExternalImage{
URL: imgData.URL,
Size: imgData.Size,
})
}
}
if len(res) == 0 {
return nil, agents.ErrNotFound
}
return res, nil
}
// deezerEmptyPicturePath is Deezer's empty-image-id path shape for artists with no picture
// (…/images/artist//1000x1000-…), which serves a generic silhouette on any CDN host.
const deezerEmptyPicturePath = "/images/artist//"
func isPlaceholderPicture(url string) bool {
return strings.Contains(url, deezerEmptyPicturePath)
}
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
if err != nil {
return nil, err
}
log.Trace(ctx, "Artists found", "count", len(artists), "searched_name", name)
for i := range artists {
log.Trace(ctx, fmt.Sprintf("Artists found #%d", i), "name", artists[i].Name, "id", artists[i].ID, "link", artists[i].Link)
if i > 2 {
break
}
}
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
// ahead of non-matches, prefer an exact-case match, then the most fans.
rank := func(a Artist) int {
switch {
case a.Name == name:
return 2
case strings.EqualFold(a.Name, name):
return 1
default:
return 0
}
}
slices.SortFunc(artists, func(a, b Artist) int {
return cmp.Or(
cmp.Compare(rank(b), rank(a)),
cmp.Compare(b.NbFan, a.NbFan),
cmp.Compare(a.ID, b.ID),
)
})
best := artists[0]
if !strings.EqualFold(best.Name, name) {
log.Trace(ctx, "No artist matched the searched name", "searched_name", name, "found_name", artists[0].Name)
return nil, agents.ErrNotFound
}
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
return new(best), nil
}
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return nil, err
}
related, err := s.client.getRelatedArtists(ctx, artist.ID)
if err != nil {
return nil, err
}
res := slice.Map(related, func(r Artist) agents.Artist {
return agents.Artist{
Name: r.Name,
}
})
if len(res) > limit {
res = res[:limit]
}
return res, nil
}
func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
artist, err := s.searchArtist(ctx, artistName)
if err != nil {
return nil, err
}
tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
if err != nil {
return nil, err
}
res := slice.Map(tracks, func(r Track) agents.Song {
return agents.Song{
Name: r.Title,
Album: r.Album.Title,
Duration: uint32(r.Duration * 1000), // Convert seconds to milliseconds
}
})
return res, nil
}
func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
artist, err := s.searchArtist(ctx, name)
if err != nil {
return "", err
}
for _, lang := range s.languages {
bio, err := s.client.getArtistBio(ctx, artist.ID, lang)
if err == nil && bio != "" {
return bio, nil
}
log.Debug(ctx, "Deezer/artist.bio returned empty/error, trying next language", "artist", name, "lang", lang, err)
}
return "", agents.ErrNotFound
}
func init() {
conf.AddHook(func() {
if conf.Server.Deezer.Enabled {
agents.Register(deezerAgentName, deezerConstructor)
}
})
}

View file

@ -1,296 +0,0 @@
package deezer
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("deezerAgent", func() {
var ctx context.Context
BeforeEach(func() {
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.Deezer.Enabled = true
})
Describe("deezerConstructor", func() {
It("uses configured languages", func() {
conf.Server.Deezer.Languages = []string{"pt", "en"}
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
})
})
Describe("searchArtist", func() {
var agent *deezerAgent
var httpClient *fakeHttpClient
BeforeEach(func() {
httpClient = &fakeHttpClient{}
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
}
})
It("picks the exact-name match with the most fans when several share the name", func() {
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":61045802,"name":"Queen","nb_fan":75},
{"id":141954732,"name":"Queen","nb_fan":397},
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
{"id":183179807,"name":"Queen","nb_fan":53},
{"id":412,"name":"Queen","nb_fan":12744378}
],"total":5}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
Expect(artist.ID).To(Equal(412))
})
It("matches the name case-insensitively", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"QUEEN","nb_fan":10},
{"id":2,"name":"queen","nb_fan":20}
],"total":2}`)),
})
artist, err := agent.searchArtist(ctx, "Queen")
Expect(err).ToNot(HaveOccurred())
Expect(artist.ID).To(Equal(2))
})
// The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled
// lookup reaching that here would record a permanent absence.
It("surfaces an exhausted quota instead of reporting the artist as not found", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
})
_, err := agent.searchArtist(ctx, "Queen")
Expect(err).To(HaveOccurred())
Expect(err).ToNot(MatchError(agents.ErrNotFound))
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
})
It("returns ErrNotFound when no result matches the name exactly", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
],"total":1}`)),
})
_, err := agent.searchArtist(ctx, "Queen")
Expect(err).To(MatchError(agents.ErrNotFound))
})
})
Describe("GetArtistImages", func() {
var agent *deezerAgent
var httpClient *fakeHttpClient
BeforeEach(func() {
httpClient = &fakeHttpClient{}
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
}
})
It("returns the real images when the artist has a picture", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":412,"name":"Queen","nb_fan":12744378,
"picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg",
"picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"}
],"total":1}`)),
})
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(HaveLen(2))
Expect(images[0].URL).To(ContainSubstring("1000x1000"))
})
It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() {
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
{"id":412,"name":"Queen","nb_fan":12744378,
"picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg",
"picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg",
"picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg",
"picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"}
],"total":1}`)),
})
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(images).To(BeEmpty())
})
})
Describe("GetArtistBiography - Language Fallback", func() {
var agent *deezerAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
// Mock search artist (returns Michael Jackson)
fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
// Mock JWT token
testJWT := createTestJWT(5 * time.Minute)
httpClient.jwtResponse = &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
}
})
setupAgent := func(languages []string) {
conf.Server.Deezer.Languages = languages
agent = &deezerAgent{
dataStore: &tests.MockDataStore{},
client: newClient(httpClient),
languages: languages,
}
}
It("returns content in first language when available (1 bio API call)", func() {
setupAgent([]string{"fr", "en"})
// French biography available
fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
Expect(httpClient.bioRequestCount).To(Equal(1))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
})
It("falls back to second language when first returns empty (2 bio API calls)", func() {
setupAgent([]string{"ja", "en"})
// Japanese returns empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
Expect(httpClient.bioRequestCount).To(Equal(2))
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
setupAgent([]string{"ja", "xx"})
// Both languages return empty biography
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.bioRequestCount).To(Equal(2))
})
})
})
// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
type langAwareHttpClient struct {
searchResponse *http.Response
jwtResponse *http.Response
bioResponses map[string]*http.Response
bioRequests []*http.Request
bioRequestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
bioResponses: make(map[string]*http.Response),
bioRequests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
// Handle search artist request
if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
if c.searchResponse != nil {
return c.searchResponse, nil
}
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
}, nil
}
// Handle JWT token request
if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
if c.jwtResponse != nil {
return c.jwtResponse, nil
}
return &http.Response{
StatusCode: 500,
Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
}, nil
}
// Handle bio request (GraphQL API)
if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
c.bioRequestCount++
c.bioRequests = append(c.bioRequests, req)
lang := req.Header.Get("Accept-Language")
if resp, ok := c.bioResponses[lang]; ok {
return resp, nil
}
// Return empty bio by default
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
}, nil
}
panic("URL not mocked: " + req.URL.String())
}

View file

@ -1,62 +0,0 @@
package deezer
type SearchArtistResults struct {
Data []Artist `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Artist struct {
ID int `json:"id"`
Name string `json:"name"`
Link string `json:"link"`
Picture string `json:"picture"`
PictureSmall string `json:"picture_small"`
PictureMedium string `json:"picture_medium"`
PictureBig string `json:"picture_big"`
PictureXl string `json:"picture_xl"`
NbAlbum int `json:"nb_album"`
NbFan int `json:"nb_fan"`
Radio bool `json:"radio"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}
type errorResponse struct {
Error *deezerError `json:"error"`
}
type RelatedArtists struct {
Data []Artist `json:"data"`
Total int `json:"total"`
}
type TopTracks struct {
Data []Track `json:"data"`
Total int `json:"total"`
Next string `json:"next"`
}
type Track struct {
ID int `json:"id"`
Title string `json:"title"`
Link string `json:"link"`
Duration int `json:"duration"`
Rank int `json:"rank"`
Preview string `json:"preview"`
Artist Artist `json:"artist"`
Album Album `json:"album"`
Contributors []Artist `json:"contributors"`
}
type Album struct {
ID int `json:"id"`
Title string `json:"title"`
Cover string `json:"cover"`
CoverSmall string `json:"cover_small"`
CoverMedium string `json:"cover_medium"`
CoverBig string `json:"cover_big"`
CoverXl string `json:"cover_xl"`
Tracklist string `json:"tracklist"`
Type string `json:"type"`
}

View file

@ -1,69 +0,0 @@
package deezer
import (
"encoding/json"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Responses", func() {
Describe("Search type=artist", func() {
It("parses the artist search result correctly ", func() {
var resp SearchArtistResults
body, err := os.ReadFile("tests/fixtures/deezer.search.artist.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(17))
michael := resp.Data[0]
Expect(michael.Name).To(Equal("Michael Jackson"))
Expect(michael.PictureXl).To(Equal("https://cdn-images.dzcdn.net/images/artist/97fae13b2b30e4aec2e8c9e0c7839d92/1000x1000-000000-80-0-0.jpg"))
})
})
Describe("Error", func() {
It("parses the error response correctly", func() {
var errorResp errorResponse
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
err := json.Unmarshal(body, &errorResp)
Expect(err).To(BeNil())
Expect(errorResp.Error.Code).To(Equal(501))
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
Describe("Related Artists", func() {
It("parses the related artists response correctly", func() {
var resp RelatedArtists
body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(20))
justice := resp.Data[0]
Expect(justice.Name).To(Equal("Justice"))
Expect(justice.ID).To(Equal(6404))
})
})
Describe("Top Tracks", func() {
It("parses the top tracks response correctly", func() {
var resp TopTracks
body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
Expect(err).To(BeNil())
err = json.Unmarshal(body, &resp)
Expect(err).To(BeNil())
Expect(resp.Data).To(HaveLen(5))
track := resp.Data[0]
Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
Expect(track.ID).To(Equal(67238732))
Expect(track.Album.Title).To(Equal("Random Access Memories"))
})
})
})

View file

@ -1,272 +0,0 @@
package gotaglib
import (
"io/fs"
"os"
"time"
"github.com/djherbis/times"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/metadata"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type testFileInfo struct {
fs.FileInfo
}
func (t testFileInfo) BirthTime() time.Time {
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
return ts.BirthTime()
}
return t.FileInfo.ModTime()
}
var _ = Describe("Extractor", func() {
toP := func(name, sortName, mbid string) model.Participant {
return model.Participant{
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
}
}
roles := []struct {
model.Role
model.ParticipantList
}{
{model.RoleComposer, model.ParticipantList{
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
}},
{model.RoleLyricist, model.ParticipantList{
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
}},
{model.RoleArranger, model.ParticipantList{
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
}},
{model.RoleConductor, model.ParticipantList{
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
}},
{model.RoleDirector, model.ParticipantList{
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
}},
{model.RoleEngineer, model.ParticipantList{
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
}},
{model.RoleProducer, model.ParticipantList{
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
}},
{model.RoleRemixer, model.ParticipantList{
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
}},
{model.RoleDJMixer, model.ParticipantList{
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
}},
{model.RoleMixer, model.ParticipantList{
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
}},
}
var e *extractor
parseTestFile := func(path string) *model.MediaFile {
mds, err := e.Parse(path)
Expect(err).ToNot(HaveOccurred())
info, ok := mds[path]
Expect(ok).To(BeTrue())
fileInfo, err := os.Stat(path)
Expect(err).ToNot(HaveOccurred())
info.FileInfo = testFileInfo{FileInfo: fileInfo}
metadata := metadata.New(path, info)
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
}
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
})
Describe("ReplayGain", func() {
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
mf := parseTestFile("tests/fixtures/" + file)
Expect(mf.RGTrackGain).To(Equal(trackGain))
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
Expect(mf.RGAlbumGain).To(Equal(albumGain))
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
},
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)),
)
})
Describe("lyrics", func() {
makeLyrics := func(code, secondLine string) model.Lyrics {
return model.Lyrics{
DisplayArtist: "",
DisplayTitle: "",
Lang: code,
Line: []model.Line{
{Start: new(int64(0)), Value: "This is"},
{Start: new(int64(2500)), Value: secondLine},
},
Offset: nil,
Synced: true,
}
}
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(2))
Expect(lyrics[0].Synced).To(BeTrue())
Expect(lyrics[1].Synced).To(BeFalse())
})
It("should handle mp3 with uslt and sylt", func() {
mf := parseTestFile("tests/fixtures/test.mp3")
lyrics, err := mf.StructuredLyrics()
Expect(err).ToNot(HaveOccurred())
Expect(lyrics).To(HaveLen(4))
engSylt := makeLyrics("eng", "English SYLT")
engUslt := makeLyrics("eng", "English")
unsSylt := makeLyrics("xxx", "unspecified SYLT")
unsUslt := makeLyrics("xxx", "unspecified")
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
})
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
mf := parseTestFile("tests/fixtures/" + file)
lyrics, err := mf.StructuredLyrics()
Expect(err).To(Not(HaveOccurred()))
Expect(lyrics).To(HaveLen(2))
unspec := makeLyrics("xxx", "unspecified")
eng := makeLyrics("xxx", "English")
if isId3 {
eng.Lang = "eng"
}
Expect(lyrics).To(Or(
Equal(model.LyricList{unspec, eng}),
Equal(model.LyricList{eng, unspec})))
},
Entry("flac", "test.flac", false),
Entry("m4a", "test.m4a", false),
Entry("ogg", "test.ogg", false),
Entry("wma", "test.wma", false),
Entry("wv", "test.wv", false),
Entry("wav", "test.wav", true),
Entry("aiff", "test.aiff", true),
)
})
Describe("Participants", func() {
DescribeTable("test tags consistent across formats", func(format string) {
mf := parseTestFile("tests/fixtures/test." + format)
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
Expect(actual).To(HaveLen(len(artists)))
for i := range artists {
actualArtist := actual[i]
expectedArtist := artists[i]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
}
}
if format != "m4a" {
performers := mf.Participants[model.RolePerformer]
Expect(performers).To(HaveLen(8))
rules := map[string][]string{
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
}
for name, rule := range rules {
mbid := rule[0]
for i := 1; i < len(rule); i++ {
found := false
for _, mapped := range performers {
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
found = true
break
}
}
Expect(found).To(BeTrue(), "Could not find matching artist")
}
}
}
},
Entry("FLAC format", "flac"),
Entry("M4a format", "m4a"),
Entry("OGG format", "ogg"),
Entry("WV format", "wv"),
Entry("MP3 format", "mp3"),
Entry("WAV format", "wav"),
Entry("AIFF format", "aiff"),
)
It("should parse wma", func() {
mf := parseTestFile("tests/fixtures/test.wma")
for _, data := range roles {
role := data.Role
artists := data.ParticipantList
actual := mf.Participants[role]
// WMA has no Arranger role
if role == model.RoleArranger {
Expect(actual).To(HaveLen(0))
continue
}
Expect(actual).To(HaveLen(len(artists)), role.String())
// For some bizarre reason, the order is inverted. We also don't get
// sort names or MBIDs
for i := range artists {
idx := len(artists) - 1 - i
actualArtist := actual[i]
expectedArtist := artists[idx]
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
}
}
})
})
})

View file

@ -1,301 +0,0 @@
// Package gotaglib provides an alternative metadata extractor using go-taglib,
// a pure Go (WASM-based) implementation of TagLib.
//
// This extractor aims for parity with the CGO-based taglib extractor. It uses
// TagLib's PropertyMap interface for standard tags. The File handle API provides
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
// through a single file open operation.
//
// This extractor is registered under the name "taglib". It only works with a filesystem
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
// must implement io.ReadSeeker for go-taglib to read them.
package gotaglib
import (
"errors"
"fmt"
"io"
"io/fs"
"runtime/debug"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/storage/local"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/metadata"
"go.senan.xyz/taglib"
)
type extractor struct {
fs fs.FS
}
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
results := make(map[string]metadata.Info)
for _, path := range files {
props, err := e.extractMetadata(path)
if err != nil {
continue
}
results[path] = *props
}
return results, nil
}
func (e extractor) Version() string {
bi, ok := debug.ReadBuildInfo()
if ok {
for _, dep := range bi.Deps {
if dep.Path == "go.senan.xyz/taglib" {
if dep.Replace != nil {
return dep.Replace.Version
}
return dep.Version
}
}
}
return "unknown"
}
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
// Recover from panics in the WASM runtime that can occur during any taglib
// operation (opening, reading tags, or reading properties). This catches crashes
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
// hardened systems with MemoryDenyWriteExecute=true).
debug.SetPanicOnFault(true)
defer func() {
if r := recover(); r != nil {
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
debug.PrintStack()
err = fmt.Errorf("WASM runtime panic: %v", r)
}
}()
f, close, err := e.openFile(filePath)
if err != nil {
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
return nil, err
}
defer close()
// Get all tags and properties in one go
allTags := f.AllTags()
props := f.Properties()
// Map properties to AudioProperties
ap := metadata.AudioProperties{
Duration: props.Length.Round(time.Millisecond * 10),
BitRate: int(props.Bitrate),
Channels: int(props.Channels),
SampleRate: int(props.SampleRate),
BitDepth: int(props.BitsPerSample),
Codec: props.Codec,
}
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
normalizedTags := make(map[string][]string, len(allTags.Tags))
for key, values := range allTags.Tags {
lowerKey := strings.ToLower(key)
normalizedTags[lowerKey] = values
}
// Process format-specific raw tags
processRawTags(allTags, normalizedTags)
// Parse track/disc totals from "N/Total" format
parseTuple(normalizedTags, "track")
parseTuple(normalizedTags, "disc")
// Adjust some ID3 tags
parseLyrics(normalizedTags)
parseTIPL(normalizedTags)
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
// Determine if file has embedded picture
hasPicture := len(props.Images) > 0
return &metadata.Info{
Tags: normalizedTags,
AudioProperties: ap,
HasPicture: hasPicture,
}, nil
}
// openFile opens the file at filePath using the extractor's filesystem.
// It returns a TagLib File handle and a cleanup function to close resources.
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
// Open the file from the filesystem
file, err := e.fs.Open(filePath)
if err != nil {
return nil, nil, err
}
rs, isSeekable := file.(io.ReadSeeker)
if !isSeekable {
file.Close()
return nil, nil, errors.New("file is not seekable")
}
// WithFilename provides a format detection hint via the file extension,
// since OpenStream alone relies on content-sniffing which fails for some files.
f, err = taglib.OpenStream(rs,
taglib.WithReadStyle(taglib.ReadStyleFast),
taglib.WithFilename(filePath),
)
if err != nil {
file.Close()
return nil, nil, err
}
closeFunc = func() {
f.Close()
file.Close()
}
return f, closeFunc, nil
}
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
func parseTuple(tags map[string][]string, prop string) {
tagName := prop + "number"
tagTotal := prop + "total"
if value, ok := tags[tagName]; ok && len(value) > 0 {
parts := strings.Split(value[0], "/")
tags[tagName] = []string{parts[0]}
if len(parts) == 2 {
tags[tagTotal] = []string{parts[1]}
}
}
}
// parseLyrics ensures lyrics tags have a language code.
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
func parseLyrics(tags map[string][]string) {
lyrics := tags["lyrics"]
if len(lyrics) > 0 {
tags["lyrics:xxx"] = lyrics
delete(tags, "lyrics")
}
}
// processRawTags processes format-specific raw tags based on the detected file format.
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
switch allTags.Format {
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
parseID3v2Frames(allTags.Raw, normalizedTags)
case taglib.FormatMP4:
parseMP4Atoms(allTags.Raw, normalizedTags)
case taglib.FormatASF:
parseASFAttributes(allTags.Raw, normalizedTags)
}
}
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
// Process frames that have language-specific data
for key, values := range rawFrames {
lowerKey := strings.ToLower(key)
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
parts := strings.SplitN(lowerKey, ":", 2)
if len(parts) == 2 && parts[1] != "" {
lang := parts[1]
lyricsKey := "lyrics:" + lang
tags[lyricsKey] = append(tags[lyricsKey], values...)
}
}
}
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
for key := range tags {
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
delete(tags, "lyrics")
break
}
}
}
const iTunesKeyPrefix = "----:com.apple.iTunes:"
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
// Process all atoms and add them to tags
for key, values := range rawAtoms {
// Strip iTunes prefix and convert to lowercase
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
normalizedKey = strings.ToLower(normalizedKey)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
// Process all attributes and add them to tags
for key, values := range rawAttrs {
normalizedKey := strings.ToLower(key)
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
if _, exists := tags[normalizedKey]; !exists {
tags[normalizedKey] = values
}
}
}
// These are the only roles we support, based on Picard's tag map:
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
var tiplMapping = map[string]string{
"arranger": "arranger",
"engineer": "engineer",
"producer": "producer",
"mix": "mixer",
"DJ-mix": "djmixer",
}
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
//
// and breaks it down into a map of roles and names, e.g.:
//
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
func parseTIPL(tags map[string][]string) {
tipl := tags["tipl"]
if len(tipl) == 0 {
return
}
addRole := func(currentRole string, currentValue []string) {
if currentRole != "" && len(currentValue) > 0 {
role := tiplMapping[currentRole]
tags[role] = append(tags[role], strings.Join(currentValue, " "))
}
}
var currentRole string
var currentValue []string
for part := range strings.SplitSeq(tipl[0], " ") {
if _, ok := tiplMapping[part]; ok {
addRole(currentRole, currentValue)
currentRole = part
currentValue = nil
continue
}
currentValue = append(currentValue, part)
}
addRole(currentRole, currentValue)
delete(tags, "tipl")
}
var _ local.Extractor = (*extractor)(nil)
func init() {
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
return &extractor{fsys}
})
conf.AddHook(func() {
log.Debug("go-taglib version", "version", extractor{}.Version())
})
}

View file

@ -1,318 +0,0 @@
package gotaglib
import (
"io/fs"
"os"
"strings"
"github.com/navidrome/navidrome/tests"
"github.com/navidrome/navidrome/utils"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Extractor", func() {
var e *extractor
BeforeEach(func() {
e = &extractor{fs: os.DirFS(".")}
})
Describe("Parse", func() {
It("correctly parses metadata from all files in folder", func() {
mds, err := e.Parse(
"tests/fixtures/test.mp3",
"tests/fixtures/test.ogg",
)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
// Test MP3
m := mds["tests/fixtures/test.mp3"]
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.HasPicture).To(BeTrue())
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
Expect(m.AudioProperties.BitRate).To(Equal(192))
Expect(m.AudioProperties.Channels).To(Equal(2))
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("tcmp", []string{"1"})),
)
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
Expect(m.Tags).ToNot(HaveKey("lyrics"))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English SYLT\n",
"[00:00.00]This is\n[00:02.50]English",
}), HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
"[00:00.00]This is\n[00:02.50]English SYLT\n",
})))
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
"[00:00.00]This is\n[00:02.50]unspecified",
}), HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
})))
// Test OGG
m = mds["tests/fixtures/test.ogg"]
Expect(err).To(BeNil())
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
// TagLib 1.12 returns 18, previous versions return 39.
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
Expect(m.HasPicture).To(BeTrue())
})
DescribeTable("Format-Specific tests",
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
file = "tests/fixtures/" + file
mds, err := e.Parse(file)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(1))
m := mds[file]
Expect(m.HasPicture).To(Equal(image))
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
Expect(m.AudioProperties.Channels).To(Equal(channels))
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
))
Expect(m.Tags).To(Or(
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
))
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
// Still as of TagLib v2.2.1, TagLib only maps values in ID3, MP4, and ASF tags
// to `originaldate`.
if strings.HasSuffix(file, ".mp3") || strings.HasSuffix(file, ".wav") || strings.HasSuffix(file, ".aiff") || strings.HasSuffix(file, ".m4a") || strings.HasSuffix(file, ".wma") {
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
}
// MP3Tag sets `ORIGYEAR` in several formats for which it has no built-in mapping
// for original release dates.
Expect(m.Tags).To(Or(
HaveKeyWithValue("origyear", []string{"1998-07-28"}),
HaveKeyWithValue("----:com.apple.itunes:origyear", []string{"1998-07-28"}),
))
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
Expect(m.Tags).To(Or(
HaveKeyWithValue("tracknumber", []string{"3"}),
HaveKeyWithValue("tracknumber", []string{"3/10"}),
))
if !strings.HasSuffix(file, "test.wma") {
// TODO Not sure why this is not working for WMA
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
}
Expect(m.Tags).To(Or(
HaveKeyWithValue("discnumber", []string{"1"}),
HaveKeyWithValue("discnumber", []string{"1/2"}),
))
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
// WMA does not have a "compilation" tag, but "wm/iscompilation"
Expect(m.Tags).To(Or(
HaveKeyWithValue("compilation", []string{"1"}),
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
)
if id3Lyrics {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
"[00:00.00]This is\n[00:02.50]English",
}))
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
}))
} else {
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
"[00:00.00]This is\n[00:02.50]unspecified",
"[00:00.00]This is\n[00:02.50]English",
}))
}
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
},
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true),
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
)
// Skip these tests when running as root
Context("Access Forbidden", func() {
var accessForbiddenFile string
var RegularUserContext = XContext
var isRegularUser = os.Getuid() != 0
if isRegularUser {
RegularUserContext = Context
}
// Only run permission tests if we are not root
RegularUserContext("when run without root privileges", func() {
BeforeEach(func() {
tests.SkipOnWindows("uses Unix file permission bits")
// Use root fs for absolute paths in temp directory
e = &extractor{fs: os.DirFS("/")}
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
Expect(err).ToNot(HaveOccurred())
DeferCleanup(func() {
Expect(f.Close()).To(Succeed())
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
})
})
It("correctly handle unreadable file due to insufficient read permission", func() {
// Strip leading slash for DirFS rooted at "/"
_, err := e.extractMetadata(accessForbiddenFile[1:])
Expect(err).To(MatchError(os.ErrPermission))
})
It("skips the file if it cannot be read", func() {
// Get current working directory to construct paths relative to root
cwd, err := os.Getwd()
Expect(err).ToNot(HaveOccurred())
// Strip leading slash for DirFS rooted at "/"
files := []string{
cwd[1:] + "/tests/fixtures/test.mp3",
cwd[1:] + "/tests/fixtures/test.ogg",
accessForbiddenFile[1:],
}
mds, err := e.Parse(files...)
Expect(err).NotTo(HaveOccurred())
Expect(mds).To(HaveLen(2))
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
})
})
})
})
Describe("Error Checking", func() {
It("returns a generic ErrPath if file does not exist", func() {
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
_, err := e.extractMetadata(testFilePath)
Expect(err).To(MatchError(fs.ErrNotExist))
})
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
// File has an empty TDAT frame
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
})
})
Describe("parseTIPL", func() {
var tags map[string][]string
BeforeEach(func() {
tags = make(map[string][]string)
})
Context("when the TIPL string is populated", func() {
It("correctly parses roles and names", func() {
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
})
It("handles multiple names for a single role", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
parseTIPL(tags)
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
It("discards roles without names", func() {
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
parseTIPL(tags)
Expect(tags).ToNot(HaveKey("producer"))
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
})
})
Context("when the TIPL string is empty", func() {
It("does nothing", func() {
tags["tipl"] = []string{""}
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
Context("when the TIPL is not present", func() {
It("does nothing", func() {
parseTIPL(tags)
Expect(tags).To(BeEmpty())
})
})
})
})

View file

@ -1,451 +0,0 @@
package lastfm
import (
"context"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"github.com/andybalholm/cascadia"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"golang.org/x/net/html"
)
const (
lastFMAgentName = "lastfm"
sessionKeyProperty = "LastFMSessionKey"
)
var ignoredContent = []string{
// Empty Artist/Album
`<a href="https://www.last.fm/music/`,
}
var lastFMReadMoreRegex = regexp.MustCompile(`\s*<a href="https://www\.last\.fm/music/[^"]*">Read more on Last\.fm</a>\.?`)
func cleanContent(content string) string {
return strings.TrimSpace(lastFMReadMoreRegex.ReplaceAllString(content, ""))
}
type lastfmAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
apiKey string
secret string
languages []string
client *client
httpClient httpDoer
getInfoMutex sync.Mutex
}
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
if !conf.Server.LastFM.Enabled || conf.Server.LastFM.ApiKey == "" || conf.Server.LastFM.Secret == "" {
return nil
}
l := &lastfmAgent{
ds: ds,
languages: conf.Server.LastFM.Languages,
apiKey: conf.Server.LastFM.ApiKey,
secret: conf.Server.LastFM.Secret,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, chc)
return l
}
func (l *lastfmAgent) AgentName() string {
return lastFMAgentName
}
var imageRegex = regexp.MustCompile(`u\/(\d+)`)
// isValidContent checks if content is non-empty and not in the ignored list
func isValidContent(content string) bool {
content = strings.TrimSpace(content)
if content == "" {
return false
}
for _, ign := range ignoredContent {
if strings.HasPrefix(content, ign) {
return false
}
}
return true
}
func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*agents.AlbumInfo, error) {
var a *Album
var resp agents.AlbumInfo
for _, lang := range l.languages {
var err error
a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
if err != nil {
return nil, err
}
resp.Name = a.Name
resp.MBID = a.MBID
resp.URL = a.URL
if isValidContent(a.Description.Summary) {
resp.Description = cleanContent(a.Description.Summary)
return &resp, nil
}
log.Debug(ctx, "LastFM/album.getInfo returned empty/ignored description, trying next language", "album", name, "artist", artist, "lang", lang)
}
// This condition should not be hit (languages default to ["en"]), but just in case
if a == nil {
return nil, agents.ErrNotFound
}
return &resp, nil
}
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0])
if err != nil {
return nil, err
}
// Last.fm can return duplicate sizes.
seenSizes := map[int]bool{}
images := make([]agents.ExternalImage, 0)
// This assumes that Last.fm returns images with size small, medium, and large.
// This is true as of December 29, 2022
for _, img := range a.Image {
size := imageRegex.FindStringSubmatch(img.URL)
// Last.fm can return images without URL
if len(size) == 0 || len(size[0]) < 4 {
log.Trace(ctx, "LastFM/albuminfo image URL does not match expected regex or is empty", "url", img.URL, "size", img.Size)
continue
}
numericSize, err := strconv.Atoi(size[0][2:])
if err != nil {
log.Error(ctx, "LastFM/albuminfo image URL does not match expected regex", "url", img.URL, "size", img.Size, err)
return nil, err
}
if _, exists := seenSizes[numericSize]; !exists {
images = append(images, agents.ExternalImage{
Size: numericSize,
URL: img.URL,
})
seenSizes[numericSize] = true
}
}
return images, nil
}
func (l *lastfmAgent) GetArtistMBID(ctx context.Context, id string, name string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return "", err
}
if a.MBID == "" {
return "", agents.ErrNotFound
}
return a.MBID, nil
}
func (l *lastfmAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return "", err
}
if a.URL == "" {
return "", agents.ErrNotFound
}
return a.URL, nil
}
func (l *lastfmAgent) GetArtistBiography(ctx context.Context, id, name, mbid string) (string, error) {
for _, lang := range l.languages {
a, err := l.callArtistGetInfo(ctx, name, lang)
if err != nil {
return "", err
}
if isValidContent(a.Bio.Summary) {
return cleanContent(a.Bio.Summary), nil
}
log.Debug(ctx, "LastFM/artist.getInfo returned empty/ignored biography, trying next language", "artist", name, "lang", lang)
}
return "", agents.ErrNotFound
}
func (l *lastfmAgent) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]agents.Artist, error) {
resp, err := l.callArtistGetSimilar(ctx, name, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
var res []agents.Artist
for _, a := range resp {
res = append(res, agents.Artist{
Name: a.Name,
MBID: a.MBID,
})
}
return res, nil
}
func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.callArtistGetTopTracks(ctx, artistName, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
var res []agents.Song
for _, t := range resp {
res = append(res, agents.Song{
Name: t.Name,
MBID: t.MBID,
})
}
return res, nil
}
func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]agents.Song, error) {
resp, err := l.callTrackGetSimilar(ctx, name, artist, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, 0, len(resp))
for _, t := range resp {
res = append(res, agents.Song{
Name: t.Name,
MBID: t.MBID,
Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}},
})
}
return res, nil
}
var (
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
// methods, which the page block does not affect.
errNoArtistPage = errors.New("no artist image in Last.fm page")
)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
a, err := l.callArtistGetInfo(ctx, name, l.languages[0])
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.URL, nil)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
resp, err := l.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
defer resp.Body.Close()
node, err := html.Parse(resp.Body)
if err != nil {
return nil, fmt.Errorf("parse html: %w", err)
}
var res []agents.ExternalImage
n := cascadia.Query(node, artistOpenGraphQuery)
if n == nil {
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
return nil, errNoArtistPage
}
for _, attr := range n.Attr {
if attr.Key != "content" {
continue
}
if strings.Contains(attr.Val, artistIgnoredImage) {
log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
return res, nil
}
res = []agents.ExternalImage{
{URL: attr.Val},
}
}
return res, nil
}
// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable —
// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed.
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
if err != nil {
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
// A not-found is a definitive absence, not a fault: return the shared sentinel so the
// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
return nil, agents.ErrNotFound
}
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
return nil, err
}
return a, nil
}
func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
l.getInfoMutex.Lock()
defer l.getInfoMutex.Unlock()
a, err := l.client.artistGetInfo(ctx, name, lang)
if err != nil {
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
// A not-found is a definitive absence, not a fault: return the shared sentinel so it
// doesn't trip the artwork worker's breaker, and log at Debug instead of Error.
log.Debug(ctx, "Artist not found in Last.fm", "artist", name)
return nil, agents.ErrNotFound
}
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
return nil, err
}
return a, nil
}
func (l *lastfmAgent) callArtistGetSimilar(ctx context.Context, name string, limit int) ([]Artist, error) {
s, err := l.client.artistGetSimilar(ctx, name, limit)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getSimilar", "artist", name, err)
return nil, err
}
return s.Artists, nil
}
func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName string, count int) ([]Track, error) {
t, err := l.client.artistGetTopTracks(ctx, artistName, count)
if err != nil {
log.Error(ctx, "Error calling LastFM/artist.getTopTracks", "artist", artistName, err)
return nil, err
}
return t.Track, nil
}
func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist string, count int) ([]SimilarTrack, error) {
s, err := l.client.trackGetSimilar(ctx, name, artist, count)
if err != nil {
log.Error(ctx, "Error calling LastFM/track.getSimilar", "track", name, "artist", artist, err)
return nil, err
}
return s.Track, nil
}
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
return track.Participants[role][0].Name
}
return displayName
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return scrobbler.ErrNotAuthorized
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
return nil
}
func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return errors.Join(err, scrobbler.ErrNotAuthorized)
}
if s.Duration <= 30 {
log.Debug(ctx, "Skipping Last.fm scrobble for short song", "track", s.Title, "duration", s.Duration)
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
timestamp: s.TimeStamp,
})
if err == nil {
return nil
}
var lfErr *lastFMError
isLastFMError := errors.As(err, &lfErr)
if !isLastFMError {
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
return errors.Join(err, scrobbler.ErrRetryLater)
}
// 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client.
if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) {
return errors.Join(err, scrobbler.ErrRetryLater)
}
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
sk, err := l.sessionKeys.Get(ctx, userId)
return err == nil && sk != ""
}
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
scrobbler.Register(lastFMAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
// Same as above - this is a workaround for the fact that a (Scrobbler)(nil) is not the same as a (*lastfmAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := lastFMConstructor(ds)
if a != nil {
return a
}
return nil
})
})
}

View file

@ -1,731 +0,0 @@
package lastfm
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
const (
lastfmError3 = `{"error":3,"message":"Invalid Method - No method with that name in this package","links":[]}`
lastfmError6 = `{"error":6,"message":"The artist you supplied could not be found","links":[]}`
)
var _ = Describe("lastfmAgent", func() {
var ds model.DataStore
var ctx context.Context
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
DeferCleanup(configtest.SetupConfig())
conf.Server.LastFM.Enabled = true
conf.Server.LastFM.ApiKey = "123"
conf.Server.LastFM.Secret = "secret"
})
Describe("lastFMConstructor", func() {
When("Agent is properly configured", func() {
It("uses configured api key and languages", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"}
agent := lastFMConstructor(ds)
Expect(agent.apiKey).To(Equal("123"))
Expect(agent.secret).To(Equal("secret"))
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
})
})
When("Agent is disabled", func() {
It("returns nil", func() {
conf.Server.LastFM.Enabled = false
Expect(lastFMConstructor(ds)).To(BeNil())
})
})
When("ApiKey is empty", func() {
It("returns nil", func() {
conf.Server.LastFM.ApiKey = ""
Expect(lastFMConstructor(ds)).To(BeNil())
})
})
When("Secret is empty", func() {
It("returns nil", func() {
conf.Server.LastFM.Secret = ""
Expect(lastFMConstructor(ds)).To(BeNil())
})
})
})
Describe("GetArtistBiography", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistBiography(ctx, "123", "U2", "")).To(Equal("U2 é uma das mais importantes bandas de rock de todos os tempos. Formada em 1976 em Dublin, composta por Bono (vocalista e guitarrista), The Edge (guitarrista, pianista e backing vocal), Adam Clayton (baixista), Larry Mullen, Jr. (baterista e percussionista).\n\nDesde a década de 80, U2 é uma das bandas mais populares no mundo. Seus shows são únicos e um verdadeiro festival de efeitos especiais, além de serem um dos que mais arrecadam anualmente."))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
StatusCode: 200,
}
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
})
})
Describe("Language Fallback", func() {
Describe("GetArtistBiography", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("returns content in first language when available (1 API call)", func() {
conf.Server.LastFM.Languages = []string{"pt", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Portuguese biography available
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.responses["pt"] = http.Response{Body: f, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("U2 é uma das mais importantes bandas de rock"))
Expect(httpClient.requestCount).To(Equal(1))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("pt"))
})
It("falls back to second language when first returns empty (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns empty/ignored biography (actual Last.fm response with just "Read more" link)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns full biography
fEn, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
bio, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(bio).To(ContainSubstring("Legião Urbana was a Brazilian post-punk band"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns ErrNotFound when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return empty/ignored biography (using actual Last.fm response format)
fJa, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// Second language also returns empty
fXx, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
_, err := agent.GetArtistBiography(ctx, "123", "Legião Urbana", "")
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.requestCount).To(Equal(2))
})
})
Describe("GetAlbumInfo", func() {
var agent *lastfmAgent
var httpClient *langAwareHttpClient
BeforeEach(func() {
httpClient = newLangAwareHttpClient()
})
It("falls back to second language when first returns empty description (2 API calls)", func() {
conf.Server.LastFM.Languages = []string{"ja", "en"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Japanese returns album without wiki/description (actual Last.fm response)
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
// English returns album with description
fEn, _ := os.Open("tests/fixtures/lastfm.album.getinfo.en.json")
httpClient.responses["en"] = http.Response{Body: fEn, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(ContainSubstring("segundo álbum de estúdio"))
Expect(httpClient.requestCount).To(Equal(2))
Expect(httpClient.requests[0].URL.Query().Get("lang")).To(Equal("ja"))
Expect(httpClient.requests[1].URL.Query().Get("lang")).To(Equal("en"))
})
It("returns album without description when all languages return empty", func() {
conf.Server.LastFM.Languages = []string{"ja", "xx"}
agent = lastFMConstructor(ds)
agent.client = newClient("API_KEY", "SECRET", httpClient)
// Both languages return album without description
fJa, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["ja"] = http.Response{Body: fJa, StatusCode: 200}
fXx, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty.json")
httpClient.responses["xx"] = http.Response{Body: fXx, StatusCode: 200}
albumInfo, err := agent.GetAlbumInfo(ctx, "Dois", "Legião Urbana", "")
Expect(err).ToNot(HaveOccurred())
Expect(albumInfo.Name).To(Equal("Dois"))
Expect(albumInfo.Description).To(BeEmpty())
Expect(httpClient.requestCount).To(Equal(2))
})
})
})
Describe("GetSimilarArtists", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns similar artists", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilarArtists(ctx, "123", "U2", "", 2)).To(Equal([]agents.Artist{
{Name: "Passengers", MBID: "e110c11f-1c94-4471-a350-c38f46b29389"},
{Name: "INXS", MBID: "481bf5f9-2e7c-4c44-b08a-05b32bc7c00d"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetSimilarArtists(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
})
Describe("GetArtistTopSongs", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns top songs", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)).To(Equal([]agents.Song{
{Name: "Beautiful Day", MBID: "f7f264d0-a89b-4682-9cd7-a4e7c37637af"},
{Name: "With or Without You", MBID: "6b9a509f-6907-4a6e-9345-2f12da09ba4b"},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetArtistTopSongs(ctx, "123", "U2", "", 2)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
})
})
Describe("GetSimilarSongsByTrack", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns similar songs", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetSimilarSongsByTrack(ctx, "123", "Just Can't Get Enough", "Depeche Mode", "", 5)).To(Equal([]agents.Song{
{Name: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
{Name: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artists: []agents.Artist{{Name: "Depeche Mode", MBID: "8538e728-ca0b-4321-b7e5-cff6565dd4c0"}}},
{Name: "Don't You Want Me", MBID: "", Artists: []agents.Artist{{Name: "The Human League", MBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"}}},
{Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}},
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artists: []agents.Artist{{Name: "New Order", MBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"}}},
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("track")).To(Equal("Just Can't Get Enough"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Depeche Mode"))
})
It("returns ErrNotFound when no similar songs found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "UnknownTrack", "UnknownArtist", "", 3)
Expect(err).To(MatchError(agents.ErrNotFound))
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetSimilarSongsByTrack(ctx, "123", "Believe", "Cher", "", 3)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
})
})
Describe("Scrobbling", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
var track *model.MediaFile
BeforeEach(func() {
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
AlbumArtist: "Track AlbumArtist",
TrackNumber: 1,
Duration: 180,
MbzRecordingID: "mbz-123",
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
model.RoleAlbumArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
},
},
}
})
Describe("NowPlaying", func() {
It("calls Last.fm with correct params", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("method")).To(Equal("track.updateNowPlaying"))
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title))
Expect(sentParams.Get("album")).To(Equal(track.Album))
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
When("ScrobbleFirstArtistOnly is true", func() {
BeforeEach(func() {
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
})
It("uses only the first artist", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
})
Describe("scrobble", func() {
It("calls Last.fm with correct params", func() {
ts := time.Now()
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("method")).To(Equal("track.scrobble"))
Expect(sentParams.Get("sk")).To(Equal("SK-1"))
Expect(sentParams.Get("track")).To(Equal(track.Title))
Expect(sentParams.Get("album")).To(Equal(track.Album))
Expect(sentParams.Get("artist")).To(Equal(track.Artist))
Expect(sentParams.Get("albumArtist")).To(Equal(track.AlbumArtist))
Expect(sentParams.Get("trackNumber")).To(Equal(strconv.Itoa(track.TrackNumber)))
Expect(sentParams.Get("duration")).To(Equal(strconv.FormatFloat(float64(track.Duration), 'G', -1, 32)))
Expect(sentParams.Get("mbid")).To(Equal(track.MbzRecordingID))
Expect(sentParams.Get("timestamp")).To(Equal(strconv.FormatInt(ts.Unix(), 10)))
})
When("ScrobbleFirstArtistOnly is true", func() {
BeforeEach(func() {
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
})
It("uses only the first artist", func() {
ts := time.Now()
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: ts})
Expect(err).ToNot(HaveOccurred())
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
sentParams, _ := url.ParseQuery(string(body))
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
It("skips songs with less than 31 seconds", func() {
track.Duration = 29
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.Scrobble(ctx, "user-2", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
It("returns ErrRetryLater on error 11", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":11,"message":"Service Offline - This service is temporarily offline. Try again later."}`)),
StatusCode: 400,
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on error 16", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":16,"message":"There was a temporary error processing your request. Please try again"}`)),
StatusCode: 400,
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
StatusCode: 200,
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
})
It("returns ErrRetryLater on http errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrUnrecoverable on other errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":8,"message":"Operation failed - Something else went wrong"}`)),
StatusCode: 400,
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
})
})
Describe("GetAlbumInfo", func() {
var agent *lastfmAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", httpClient)
agent = lastFMConstructor(ds)
agent.client = client
})
It("returns the biography", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetAlbumInfo(ctx, "Believe", "Cher", "03c91c40-49a6-44a7-90e7-a700edf97a62")).To(Equal(&agents.AlbumInfo{
Name: "Believe",
MBID: "03c91c40-49a6-44a7-90e7-a700edf97a62",
Description: "Believe is the twenty-third studio album by American singer-actress Cher, released on November 10, 1998 by Warner Bros. Records. The RIAA certified it Quadruple Platinum on December 23, 1999, recognizing four million shipments in the United States; Worldwide, the album has sold more than 20 million copies, making it the biggest-selling album of her career. In 1999 the album received three Grammy Awards nominations including \"Record of the Year\", \"Best Pop Album\" and winning \"Best Dance Recording\" for the single \"Believe\". It was released by Warner Bros. Records at the end of 1998. The album was executive produced by Rob",
URL: "https://www.last.fm/music/Cher/Believe",
}))
Expect(httpClient.RequestCount).To(Equal(1))
// MBID is deliberately not sent — album.getInfo matches on name+artist only.
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher"))
})
It("returns empty images if no images are available", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.empty_urls.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetAlbumInfo(ctx, "The Definitive Less Damage And More Joy", "The Jesus and Mary Chain", "")).To(Equal(&agents.AlbumInfo{
Name: "The Definitive Less Damage And More Joy",
URL: "https://www.last.fm/music/The+Jesus+and+Mary+Chain/The+Definitive+Less+Damage+And+More+Joy",
}))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("The Definitive Less Damage And More Joy"))
})
It("returns an error if Last.fm call fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
It("returns an error if Last.fm call returns an error", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError3)), StatusCode: 200}
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
Expect(err).To(HaveOccurred())
// A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
})
})
Describe("GetArtistImages", func() {
var agent *lastfmAgent
var apiClient *tests.FakeHttpClient
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
apiClient = &tests.FakeHttpClient{}
httpClient = &tests.FakeHttpClient{}
client := newClient("API_KEY", "SECRET", apiClient)
agent = lastFMConstructor(ds)
agent.client = client
agent.httpClient = httpClient
})
It("returns the artist image from the page", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(HaveLen(1))
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
})
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
// Not a fault: runs of missing artists must not trip the worker's circuit breaker.
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
})
It("returns empty list if image is the ignored default image", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).ToNot(HaveOccurred())
Expect(images).To(BeEmpty())
})
It("errors when the page has no meta tags", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(MatchError(errNoArtistPage))
})
It("errors when Last.fm serves a bot challenge page", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(MatchError(errNoArtistPage))
Expect(images).To(BeEmpty())
})
It("does not park the agent: the failure is not a retry-later", func() {
// A RetryLaterError would cool down the agent's API-backed methods too.
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
})
It("returns error if API call fails", func() {
apiClient.Err = errors.New("api error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get artist info"))
})
It("returns error if scraper call fails", func() {
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
httpClient.Err = errors.New("scraper error")
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("get artist url"))
})
})
})
// langAwareHttpClient is a mock HTTP client that returns different responses based on the lang parameter
type langAwareHttpClient struct {
responses map[string]http.Response
requests []*http.Request
requestCount int
}
func newLangAwareHttpClient() *langAwareHttpClient {
return &langAwareHttpClient{
responses: make(map[string]http.Response),
requests: make([]*http.Request, 0),
}
}
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
c.requestCount++
c.requests = append(c.requests, req)
lang := req.URL.Query().Get("lang")
if resp, ok := c.responses[lang]; ok {
return &resp, nil
}
// Return default empty response if no specific response is configured
return &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
}, nil
}

View file

@ -1,227 +0,0 @@
package lastfm
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"time"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("auth_router", func() {
var (
ds *tests.MockDataStore
userProps *tests.MockedUserPropsRepo
httpClient *tests.FakeHttpClient
router *Router
)
const (
victimID = "victim-user-id"
attackerID = "attacker-user-id"
)
BeforeEach(func() {
userProps = &tests.MockedUserPropsRepo{}
ds = &tests.MockDataStore{
MockedProperty: &tests.MockedPropertyRepo{},
MockedUserProps: userProps,
}
auth.Init(ds)
httpClient = &tests.FakeHttpClient{}
router = &Router{
ds: ds,
apiKey: "API_KEY",
secret: "SECRET",
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
}
router.client = newClient(router.apiKey, router.secret, httpClient)
router.Handler = router.routes()
})
storedSessionKey := func(userID string) string {
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
return key
}
stubGetSessionOK := func(sessionKey string) {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)),
StatusCode: 200,
}
}
Describe("getLinkStatus", func() {
It("includes a signed linkToken for the authenticated user", func() {
req := httptest.NewRequest(http.MethodGet, "/link", nil)
ctx := request.WithUser(req.Context(), model.User{ID: victimID})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
router.getLinkStatus(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
var body map[string]any
Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed())
Expect(body["apiKey"]).To(Equal("API_KEY"))
Expect(body["status"]).To(Equal(false))
token, ok := body["linkToken"].(string)
Expect(ok).To(BeTrue())
Expect(token).ToNot(BeEmpty())
verified, err := verifyLinkToken(token)
Expect(err).ToNot(HaveOccurred())
Expect(verified).To(Equal(victimID))
})
})
Describe("callback", func() {
It("stores the session key under the user encoded in the signed token", func() {
stubGetSessionOK("LEGIT_SESSION")
linkToken, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION"))
})
It("rejects a raw (unsigned) uid value", func() {
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(victimID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("rejects an expired link token", func() {
expiredToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": linkTokenScope,
"exp": time.Now().Add(-1 * time.Minute).UTC().Unix(),
})
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(victimID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() {
sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"})
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
Expect(storedSessionKey(attackerID)).To(BeEmpty())
Expect(httpClient.SavedRequest).To(BeNil())
})
It("writes only under the user encoded in the token, regardless of query manipulation", func() {
// An attacker holds a legitimate link token for their own account.
// They attempt to call the callback hoping to overwrite the victim's
// session key — but the handler must derive the user ID from the
// signed token, not from any other input.
stubGetSessionOK("ATTACKER_SESSION")
attackerToken, err := createLinkToken(attackerID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION"))
Expect(storedSessionKey(victimID)).To(BeEmpty())
})
It("returns 400 when uid is missing", func() {
req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
It("returns 400 when token is missing", func() {
linkToken, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil)
rec := httptest.NewRecorder()
router.callback(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("link token helpers", func() {
It("round-trips a freshly issued token", func() {
token, err := createLinkToken(victimID)
Expect(err).ToNot(HaveOccurred())
uid, err := verifyLinkToken(token)
Expect(err).ToNot(HaveOccurred())
Expect(uid).To(Equal(victimID))
})
It("rejects garbage", func() {
_, err := verifyLinkToken("not-a-jwt")
Expect(err).To(HaveOccurred())
})
It("rejects a token whose scope claim is wrong", func() {
wrongScopeToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": "some-other-scope",
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
})
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(wrongScopeToken)
Expect(err).To(MatchError("invalid link token scope"))
})
It("rejects a scoped token that has no expiration", func() {
nonExpiringToken, err := auth.EncodeToken(map[string]any{
"uid": victimID,
"scope": linkTokenScope,
})
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(nonExpiringToken)
Expect(err).To(MatchError("link token missing expiration"))
})
It("rejects a Jellyfin access token", func() {
usr := &model.User{ID: "u1", UserName: "johndoe"}
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
Expect(err).ToNot(HaveOccurred())
_, err = verifyLinkToken(tokenStr)
Expect(err).To(HaveOccurred())
})
})
})

View file

@ -1,312 +0,0 @@
package lastfm
import (
"bytes"
"context"
"crypto/md5"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *tests.FakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = newClient("API_KEY", "SECRET", httpClient)
})
Describe("albumGetInfo", func() {
It("returns an album on successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
Expect(err).To(BeNil())
Expect(album.Name).To(Equal("Believe"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
})
It("does not double-encode plus signs", func() {
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
})
})
Describe("artistGetInfo", func() {
It("returns an artist for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(BeNil())
Expect(artist.Name).To(Equal("U2"))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
})
It("fails if Last.fm returns an http status != 200", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
StatusCode: 500,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("last.fm http status: (500)"))
})
It("fails if Last.fm returns an http status != 200", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
StatusCode: 400,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
})
It("fails if Last.fm returns an error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
})
It("fails if HttpClient.Do() returns error", func() {
httpClient.Err = errors.New("generic error")
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("generic error"))
})
It("fails if returned body is not a valid JSON", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`<xml>NOT_VALID_JSON</xml>`)),
StatusCode: 200,
}
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
})
})
Describe("artistGetSimilar", func() {
It("returns an artist for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
Expect(err).To(BeNil())
Expect(len(similar.Artists)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
})
})
Describe("artistGetTopTracks", func() {
It("returns top tracks for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
Expect(err).To(BeNil())
Expect(len(top.Track)).To(Equal(2))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
})
It("double-encodes plus signs in the artist name", func() {
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
})
})
Describe("trackGetSimilar", func() {
It("returns similar tracks for a successful response", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
Expect(err).To(BeNil())
Expect(len(similar.Track)).To(Equal(5))
Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
Expect(similar.Track[0].Match).To(Equal(1.0))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
})
It("double-encodes plus signs in the track and artist names", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
Expect(err).ToNot(HaveOccurred())
query := httpClient.SavedRequest.URL.Query()
Expect(query.Get("track")).To(Equal("1%2B1"))
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
})
It("returns empty list when no similar tracks found", func() {
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
Expect(err).To(BeNil())
Expect(similar.Track).To(BeEmpty())
})
})
Describe("GetToken", func() {
It("returns a token when the request is successful", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)),
StatusCode: 200,
}
Expect(client.GetToken(context.Background())).To(Equal("TOKEN"))
queryParams := httpClient.SavedRequest.URL.Query()
Expect(queryParams.Get("method")).To(Equal("auth.getToken"))
Expect(queryParams.Get("format")).To(Equal("json"))
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("getSession", func() {
It("returns a session key when the request is successful", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)),
StatusCode: 200,
}
Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY"))
queryParams := httpClient.SavedRequest.URL.Query()
Expect(queryParams.Get("method")).To(Equal("auth.getSession"))
Expect(queryParams.Get("format")).To(Equal("json"))
Expect(queryParams.Get("token")).To(Equal("TOKEN"))
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("scrobble", func() {
It("sends parameters in request body for POST", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
StatusCode: 200,
}
info := ScrobbleInfo{
artist: "U2",
track: "One",
album: "Achtung Baby",
trackNumber: 1,
duration: 276,
albumArtist: "U2",
}
err := client.scrobble(context.Background(), "SESSION_KEY", info)
Expect(err).To(BeNil())
req := httpClient.SavedRequest
Expect(req.Method).To(Equal(http.MethodPost))
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
Expect(req.URL.RawQuery).To(BeEmpty())
body, _ := io.ReadAll(req.Body)
bodyParams, _ := url.ParseQuery(string(body))
Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
Expect(bodyParams.Get("artist")).To(Equal("U2"))
Expect(bodyParams.Get("track")).To(Equal("One"))
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("updateNowPlaying", func() {
It("sends parameters in request body for POST", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
StatusCode: 200,
}
info := ScrobbleInfo{
artist: "U2",
track: "One",
album: "Achtung Baby",
trackNumber: 1,
duration: 276,
albumArtist: "U2",
}
err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
Expect(err).To(BeNil())
req := httpClient.SavedRequest
Expect(req.Method).To(Equal(http.MethodPost))
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
Expect(req.URL.RawQuery).To(BeEmpty())
body, _ := io.ReadAll(req.Body)
bodyParams, _ := url.ParseQuery(string(body))
Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
Expect(bodyParams.Get("artist")).To(Equal("U2"))
Expect(bodyParams.Get("track")).To(Equal("One"))
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
})
})
Describe("sign", func() {
It("adds an api_sig param with the signature", func() {
params := url.Values{}
params.Add("d", "444")
params.Add("callback", "https://myserver.com")
params.Add("a", "111")
params.Add("format", "json")
params.Add("c", "333")
params.Add("b", "222")
client.sign(params)
Expect(params).To(HaveKey("api_sig"))
sig := params.Get("api_sig")
expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET")))
Expect(sig).To(Equal(expected))
})
})
})

View file

@ -1,50 +0,0 @@
package lastfm
import (
"errors"
"time"
"github.com/navidrome/navidrome/core/auth"
)
const (
linkTokenScope = "lastfm-link"
linkTokenTTL = 5 * time.Minute
)
// createLinkToken issues a signed token binding the Last.fm callback to the
// user who initiated the OAuth flow. It travels back through Last.fm via the
// `cb` URL in place of the previously-trusted raw `uid` query parameter.
func createLinkToken(userID string) (string, error) {
claims := map[string]any{
"uid": userID,
"scope": linkTokenScope,
"exp": time.Now().Add(linkTokenTTL).UTC().Unix(),
}
return auth.EncodeToken(claims)
}
// verifyLinkToken validates a signed link token and returns the encoded user ID.
// It enforces both the signature/expiry (via the underlying JWT verifier) and a
// dedicated scope claim, preventing tokens minted for other purposes (e.g. a
// regular session JWT) from being accepted here.
func verifyLinkToken(tokenStr string) (string, error) {
token, err := auth.DecodeAndVerifyToken(tokenStr)
if err != nil {
return "", err
}
// jwtauth treats a token without `exp` as non-expiring; require it
// explicitly so an accidental regression cannot mint permanent tokens.
if exp, ok := token.Expiration(); !ok || exp.IsZero() {
return "", errors.New("link token missing expiration")
}
var scope string
if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope {
return "", errors.New("invalid link token scope")
}
var uid string
if err := token.Get("uid", &uid); err != nil || uid == "" {
return "", errors.New("invalid link token user ID")
}
return uid, nil
}

View file

@ -1,261 +0,0 @@
package listenbrainz
import (
"context"
"errors"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
"github.com/navidrome/navidrome/utils/httpclient"
"github.com/navidrome/navidrome/utils/slice"
)
const (
listenBrainzAgentName = "listenbrainz"
sessionKeyProperty = "ListenBrainzSessionKey"
)
type listenBrainzAgent struct {
ds model.DataStore
sessionKeys *agents.SessionKeys
baseURL string
client *client
}
func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
l := &listenBrainzAgent{
ds: ds,
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
baseURL: conf.Server.ListenBrainz.BaseURL,
}
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
l.client = newClient(l.baseURL, chc)
return l
}
func (l *listenBrainzAgent) AgentName() string {
return listenBrainzAgentName
}
func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
return p.MbzArtistID
})
artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
return p.Name
})
li := listenInfo{
TrackMetadata: trackMetadata{
ArtistName: track.Artist,
TrackName: track.Title,
ReleaseName: track.Album,
AdditionalInfo: additionalInfo{
SubmissionClient: consts.AppName,
SubmissionClientVersion: consts.Version,
TrackNumber: track.TrackNumber,
ArtistNames: artistNames,
ArtistMBIDs: artistMBIDs,
RecordingMBID: track.MbzRecordingID,
ReleaseMBID: track.MbzAlbumID,
ReleaseGroupMBID: track.MbzReleaseGroupID,
DurationMs: int(track.Duration * 1000),
},
},
}
return li
}
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return errors.Join(err, scrobbler.ErrNotAuthorized)
}
li := l.formatListen(track)
err = l.client.updateNowPlaying(ctx, sk, li)
if err != nil {
log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
return nil
}
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
sk, err := l.sessionKeys.Get(ctx, userId)
if err != nil || sk == "" {
return errors.Join(err, scrobbler.ErrNotAuthorized)
}
li := l.formatListen(&s.MediaFile)
li.ListenedAt = int(s.TimeStamp.Unix())
err = l.client.scrobble(ctx, sk, li)
if err == nil {
return nil
}
var lbErr *listenBrainzError
isListenBrainzError := errors.As(err, &lbErr)
if !isListenBrainzError {
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
return errors.Join(err, scrobbler.ErrRetryLater)
}
if lbErr.Code == 500 || lbErr.Code == 503 {
return errors.Join(err, scrobbler.ErrRetryLater)
}
return errors.Join(err, scrobbler.ErrUnrecoverable)
}
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
sk, err := l.sessionKeys.Get(ctx, userId)
return err == nil && sk != ""
}
func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
if mbid == "" {
return "", agents.ErrNotFound
}
url, err := l.client.getArtistUrl(ctx, mbid)
if err != nil {
return "", err
}
return url, nil
}
func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
res := make([]agents.Song, len(resp))
for i, t := range resp {
res[i] = agents.Song{
Album: t.ReleaseName,
AlbumMBID: t.ReleaseMBID,
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
Duration: t.DurationMs,
Name: t.RecordingName,
MBID: t.RecordingMbid,
}
}
return res, nil
}
// topSongArtists maps the top-recordings response, which carries a single combined display name
// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not
// positionally pairable, so the display name attaches to the first credit and any further MBIDs
// become MBID-only collaborators — still valid identity signals for the matcher.
func topSongArtists(name string, mbids []string) []agents.Artist {
if len(mbids) == 0 {
if name == "" {
return nil
}
return []agents.Artist{{Name: name}}
}
artists := make([]agents.Artist, len(mbids))
artists[0] = agents.Artist{Name: name, MBID: mbids[0]}
for i, m := range mbids[1:] {
artists[i+1] = agents.Artist{MBID: m}
}
return artists
}
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
artists := make([]agents.Artist, len(resp))
for i, artist := range resp {
artists[i] = agents.Artist{
MBID: artist.MBID,
Name: artist.Name,
}
}
return artists, nil
}
func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
if mbid == "" {
return nil, agents.ErrNotFound
}
resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
if err != nil {
return nil, err
}
if len(resp) == 0 {
return nil, agents.ErrNotFound
}
songs := make([]agents.Song, len(resp))
for i, song := range resp {
songs[i] = agents.Song{
Album: song.ReleaseName,
AlbumMBID: song.ReleaseMBID,
Artists: []agents.Artist{{Name: song.Artist}},
MBID: song.MBID,
Name: song.Name,
}
}
return songs, nil
}
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
return nil
}
func init() {
conf.AddHook(func() {
if conf.Server.ListenBrainz.Enabled {
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
// See https://go.dev/doc/faq#nil_error
a := listenBrainzConstructor(ds)
if a != nil {
return a
}
return nil
})
}
})
}
var (
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
)

View file

@ -1,479 +0,0 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"time"
"github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
. "github.com/onsi/gomega/gstruct"
)
var _ = Describe("listenBrainzAgent", func() {
var ds model.DataStore
var ctx context.Context
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
var track *model.MediaFile
BeforeEach(func() {
ds = &tests.MockDataStore{}
ctx = context.Background()
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
httpClient = &tests.FakeHttpClient{}
agent = listenBrainzConstructor(ds)
agent.client = newClient("http://localhost:8080", httpClient)
track = &model.MediaFile{
ID: "123",
Title: "Track Title",
Album: "Track Album",
Artist: "Track Artist",
TrackNumber: 1,
MbzRecordingID: "mbz-123",
MbzAlbumID: "mbz-456",
MbzReleaseGroupID: "mbz-789",
Duration: 142.2,
Participants: map[model.Role]model.ParticipantList{
model.RoleArtist: []model.Participant{
{Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
{Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
},
},
}
})
Describe("formatListen", func() {
It("constructs the listenInfo properly", func() {
lr := agent.formatListen(track)
Expect(lr).To(MatchAllFields(Fields{
"ListenedAt": Equal(0),
"TrackMetadata": MatchAllFields(Fields{
"ArtistName": Equal(track.Artist),
"TrackName": Equal(track.Title),
"ReleaseName": Equal(track.Album),
"AdditionalInfo": MatchAllFields(Fields{
"SubmissionClient": Equal(consts.AppName),
"SubmissionClientVersion": Equal(consts.Version),
"TrackNumber": Equal(track.TrackNumber),
"RecordingMBID": Equal(track.MbzRecordingID),
"ReleaseMBID": Equal(track.MbzAlbumID),
"ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
"ArtistNames": ConsistOf("Artist 1", "Artist 2"),
"ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
"DurationMs": Equal(142200),
}),
}),
}))
})
})
Describe("NowPlaying", func() {
It("updates NowPlaying successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.NowPlaying(ctx, "user-1", track, 0)
Expect(err).ToNot(HaveOccurred())
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
})
Describe("Scrobble", func() {
var sc scrobbler.Scrobble
BeforeEach(func() {
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
})
It("sends a Scrobble successfully", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
})
It("sets the Timestamp properly", func() {
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).ToNot(HaveOccurred())
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
var lr listenBrainzRequestBody
err = decoder.Decode(&lr)
Expect(err).ToNot(HaveOccurred())
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
})
It("returns ErrNotAuthorized if user is not linked", func() {
err := agent.Scrobble(ctx, "user-2", sc)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
It("returns ErrRetryLater on error 503", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
StatusCode: 503,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on error 500", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrRetryLater on http errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
StatusCode: 500,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
})
It("returns ErrUnrecoverable on other errors", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
StatusCode: 400,
}
err := agent.Scrobble(ctx, "user-1", sc)
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
})
It("keeps a 429 scrobble for retry and carries the delay", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)),
}
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(7 * time.Second))
})
})
Describe("GetArtistUrl", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns artist url when MBID present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns error when url not present", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when fetch calls fails", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
It("returns error when ListenBrainz returns an error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
})
})
Describe("GetTopSongs", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
})
It("returns all tracks when asked", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
{
ID: "",
Name: "String Theocracy",
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
Album: "String Theocracy",
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
Duration: 174000,
},
}))
})
It("returns only one track when prompted", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]agents.Song{
{
ID: "",
Name: "world.execute(me);",
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
Album: "Miracle Milk",
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
Duration: 211912,
},
}))
})
It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() {
body := `[{
"recording_name": "Collab",
"recording_mbid": "rec-1",
"artist_name": "Drake feat. Future",
"artist_mbids": ["mbid-drake", "mbid-future"],
"release_name": "Album",
"release_mbid": "rel-1",
"length": 200000
}]`
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(HaveLen(1))
Expect(data[0].Artists).To(Equal([]agents.Artist{
{Name: "Drake feat. Future", MBID: "mbid-drake"},
{MBID: "mbid-future"},
}))
})
It("leaves Artists nil when the top song carries no name or MBIDs", func() {
body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]`
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(HaveLen(1))
Expect(data[0].Artists).To(BeNil())
})
})
Describe("GetSimilarArtists", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
}))
})
})
Describe("GetSimilarTracks", func() {
var agent *listenBrainzAgent
var httpClient *tests.FakeHttpClient
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client := newClient("BASE_URL", httpClient)
agent = listenBrainzConstructor(ds)
agent.client = client
})
It("returns error when fetch calls", func() {
httpClient.Err = errors.New("error")
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
})
It("returns an error on listenbrainz error", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
Expect(err).To(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
})
It("returns all data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artists: []agents.Artist{{Name: "a‐ha"}},
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
{
ID: "",
Name: "Wake Me Up Before You Go‐Go",
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
ISRC: "",
Artists: []agents.Artist{{Name: "Wham!"}},
Album: "Make It Big",
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
Duration: 0,
},
}))
})
It("returns subset of data on call", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.RequestCount).To(Equal(1))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
Expect(resp).To(Equal([]agents.Song{
{
ID: "",
Name: "Take On Me",
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
ISRC: "",
Artists: []agents.Artist{{Name: "a‐ha"}},
Album: "Hunting High and Low",
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Duration: 0,
},
}))
})
})
})

View file

@ -1,395 +0,0 @@
package listenbrainz
import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"slices"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/log"
)
const (
lbzApiUrl = "https://api.listenbrainz.org/1/"
labsBase = "https://labs.api.listenbrainz.org/"
)
// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In
// (delta-seconds) on every response, including the 429, and never Retry-After.
func retryLaterErr(h http.Header) *agents.RetryLaterError {
return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))}
}
var (
ErrorNotFound = errors.New("listenbrainz: not found")
)
type listenBrainzError struct {
Code int
Message string
}
func (e *listenBrainzError) Error() string {
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
}
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func newClient(baseURL string, hc httpDoer) *client {
return &client{baseURL, hc}
}
type client struct {
baseURL string
hc httpDoer
}
type listenBrainzResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Error string `json:"error"`
Status string `json:"status"`
Valid bool `json:"valid"`
UserName string `json:"user_name"`
}
type listenBrainzRequest struct {
ApiKey string //nolint:gosec
Body listenBrainzRequestBody
}
type listenBrainzRequestBody struct {
ListenType listenType `json:"listen_type,omitempty"`
Payload []listenInfo `json:"payload,omitempty"`
}
type listenType string
const (
Single listenType = "single"
PlayingNow listenType = "playing_now"
)
type listenInfo struct {
ListenedAt int `json:"listened_at,omitempty"`
TrackMetadata trackMetadata `json:"track_metadata"`
}
type trackMetadata struct {
ArtistName string `json:"artist_name,omitempty"`
TrackName string `json:"track_name,omitempty"`
ReleaseName string `json:"release_name,omitempty"`
AdditionalInfo additionalInfo `json:"additional_info"`
}
type additionalInfo struct {
SubmissionClient string `json:"submission_client,omitempty"`
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
TrackNumber int `json:"tracknumber,omitempty"`
ArtistNames []string `json:"artist_names,omitempty"`
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
RecordingMBID string `json:"recording_mbid,omitempty"`
ReleaseMBID string `json:"release_mbid,omitempty"`
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
DurationMs int `json:"duration_ms,omitempty"`
}
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
r := &listenBrainzRequest{
ApiKey: apiKey,
}
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
if err != nil {
return nil, err
}
return response, nil
}
func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: PlayingNow,
Payload: []listenInfo{li},
},
}
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
}
return nil
}
func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
r := &listenBrainzRequest{
ApiKey: apiKey,
Body: listenBrainzRequestBody{
ListenType: Single,
Payload: []listenInfo{li},
},
}
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
if err != nil {
return err
}
if resp.Status != "ok" {
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
}
return nil
}
func (c *client) path(endpoint string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", err
}
u.Path = path.Join(u.Path, endpoint)
return u.String(), nil
}
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
b, _ := json.Marshal(r.Body)
uri, err := c.path(endpoint)
if err != nil {
return nil, err
}
req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
if r.ApiKey != "" {
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
}
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, retryLaterErr(resp.Header)
}
decoder := json.NewDecoder(resp.Body)
var response listenBrainzResponse
jsonErr := decoder.Decode(&response)
if resp.StatusCode != 200 && jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if jsonErr != nil {
return nil, jsonErr
}
if response.Code != 0 && response.Code != 200 {
// LB also reports rate limiting as a body code, not only as an HTTP status.
if response.Code == http.StatusTooManyRequests {
return &response, retryLaterErr(resp.Header)
}
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
}
return &response, nil
}
type lbzHttpError struct {
Code int `json:"code"`
Error string `json:"error"`
}
func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = params.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
// On a 200 code, there is no code. Decode using using error message if it exists
if resp.StatusCode != 200 {
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
return nil, retryLaterErr(resp.Header)
}
decoder := json.NewDecoder(resp.Body)
var lbzError lbzHttpError
jsonErr := decoder.Decode(&lbzError)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
}
return resp, err
}
type artistMetadataResult struct {
Rels struct {
OfficialHomepage string `json:"official homepage,omitempty"`
} `json:"rels,omitzero"`
}
func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
params := url.Values{}
params.Add("artist_mbids", mbid)
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
if err != nil {
return "", err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []artistMetadataResult
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
return "", ErrorNotFound
}
return response[0].Rels.OfficialHomepage, nil
}
type trackInfo struct {
ArtistName string `json:"artist_name"`
ArtistMBIDs []string `json:"artist_mbids"`
DurationMs uint32 `json:"length"`
RecordingName string `json:"recording_name"`
RecordingMbid string `json:"recording_mbid"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
}
func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var response []trackInfo
jsonErr := decoder.Decode(&response)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(response) > count {
return response[0:count], nil
}
return response, nil
}
type artist struct {
MBID string `json:"artist_mbid"`
Name string `json:"name"`
Score int `json:"score"`
}
func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var artists []artist
jsonErr := decoder.Decode(&artists)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
if len(artists) > limit {
return artists[:limit], nil
}
return artists, nil
}
type recording struct {
MBID string `json:"recording_mbid"`
Name string `json:"recording_name"`
Artist string `json:"artist_credit_name"`
ReleaseName string `json:"release_name"`
ReleaseMBID string `json:"release_mbid"`
Score int `json:"score"`
}
func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
req.URL.RawQuery = url.Values{
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
}.Encode()
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
resp, err := c.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
decoder := json.NewDecoder(resp.Body)
var recordings []recording
jsonErr := decoder.Decode(&recordings)
if jsonErr != nil {
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
}
// For whatever reason, labs API isn't guaranteed to give results in the proper order
// and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
// generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
slices.SortFunc(recordings, func(a, b recording) int {
return cmp.Or(
cmp.Compare(b.Score, a.Score), // Sort by score descending
cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
)
})
recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
return a.MBID == b.MBID
})
if len(recordings) > limit {
return recordings[:limit], nil
}
return recordings, nil
}

View file

@ -1,537 +0,0 @@
package listenbrainz
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("client", func() {
var httpClient *tests.FakeHttpClient
var client *client
BeforeEach(func() {
httpClient = &tests.FakeHttpClient{}
client = newClient("BASE_URL/", httpClient)
})
Describe("listenBrainzResponse", func() {
It("parses a response properly", func() {
var response listenBrainzResponse
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
Expect(err).ToNot(HaveOccurred())
Expect(response.Code).To(Equal(200))
Expect(response.Message).To(Equal("Message"))
Expect(response.UserName).To(Equal("UserName"))
Expect(response.Valid).To(BeTrue())
Expect(response.Status).To(Equal("ok"))
Expect(response.Error).To(Equal("Error"))
})
})
Describe("validateToken", func() {
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
StatusCode: 200,
}
})
It("formats the request properly", func() {
_, err := client.validateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("parses and returns the response", func() {
res, err := client.validateToken(context.Background(), "LB-TOKEN")
Expect(err).ToNot(HaveOccurred())
Expect(res.Valid).To(Equal(true))
Expect(res.UserName).To(Equal("ListenBrainzUser"))
})
})
Context("with listenInfo", func() {
var li listenInfo
BeforeEach(func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
StatusCode: 200,
}
li = listenInfo{
TrackMetadata: trackMetadata{
ArtistName: "Track Artist",
TrackName: "Track Title",
ReleaseName: "Track Album",
AdditionalInfo: additionalInfo{
TrackNumber: 1,
ArtistNames: []string{"Artist 1", "Artist 2"},
ArtistMBIDs: []string{"mbz-789", "mbz-012"},
RecordingMBID: "mbz-123",
ReleaseMBID: "mbz-456",
DurationMs: 142200,
},
},
}
})
Describe("updateNowPlaying", func() {
It("formats the request properly", func() {
Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
Expect(body).To(MatchJSON(f))
})
})
Describe("scrobble", func() {
BeforeEach(func() {
li.ListenedAt = 1635000000
})
It("formats the request properly", func() {
Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
Expect(body).To(MatchJSON(f))
})
})
})
Context("getArtistUrl", func() {
baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
StatusCode: 400,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without meaningful body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 501,
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns not found when the artist has no official homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
_, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
Expect(err.Error()).To(Equal("listenbrainz: not found"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns data when the artist has a homepage", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
Expect(err).ToNot(HaveOccurred())
Expect(url).To(Equal("http://projectmili.com/"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getArtistTopSongs", func() {
baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
StatusCode: 400,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 50)
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles a malformed request without standard body", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(``)),
StatusCode: 500,
}
_, err := client.getArtistTopSongs(context.Background(), "1", 1)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns all tracks when given the opportunity", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 174000,
RecordingName: "String Theocracy",
RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
ReleaseName: "String Theocracy",
ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("It returns a subset of tracks when allowed", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
Expect(err).ToNot(HaveOccurred())
Expect(data).To(Equal([]trackInfo{
{
ArtistName: "Mili",
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
DurationMs: 211912,
RecordingName: "world.execute(me);",
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
ReleaseName: "Miracle Milk",
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
},
}))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
})
Context("getSimilarArtists", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
}
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarArtists(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
}))
})
It("fetches a different endpoint when algorithm changes", func() {
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]artist{
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
}))
})
})
Context("getSimilarRecordings", func() {
var algorithm string
BeforeEach(func() {
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
DeferCleanup(configtest.SetupConfig())
})
getUrl := func(mbid string) string {
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
}
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
It("handles a malformed request with status code", func() {
httpClient.Res = http.Response{
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
StatusCode: 400,
}
_, err := client.getSimilarRecordings(context.Background(), "1", 2)
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
})
It("handles real data properly", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "a‐ha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You Go‐Go",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
}))
})
It("truncates data when requested", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "a‐ha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
It("properly sorts by score and truncates duplicates", func() {
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
// There are actually 5 items. The dedup should happen FIRST
resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "a‐ha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
{
MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
Name: "Everybody Wants to Rule the World",
Artist: "Tears for Fears",
ReleaseName: "Songs From the Big Chair",
ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
Score: 68,
},
{
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
Name: "Wake Me Up Before You Go‐Go",
Artist: "Wham!",
ReleaseName: "Make It Big",
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
Score: 65,
},
{
MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
Name: "Tainted Love",
Artist: "Soft Cell",
ReleaseName: "Non-Stop Erotic Cabaret",
ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
Score: 61,
},
}))
})
It("uses a different algorithm when configured", func() {
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
httpClient.Res = http.Response{Body: f, StatusCode: 200}
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
Expect(err).ToNot(HaveOccurred())
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
Expect(resp).To(Equal([]recording{
{
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
Name: "Take On Me",
Artist: "a‐ha",
ReleaseName: "Hunting High and Low",
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
Score: 124,
},
}))
})
})
Describe("rate limiting", func() {
It("returns RetryLaterError with the header delay on 429", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
}
_, err := client.validateToken(context.Background(), "token")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(3 * time.Second))
})
It("returns RetryLaterError with zero delay when no header is present", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
}
_, err := client.validateToken(context.Background(), "token")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, _ := errors.AsType[*agents.RetryLaterError](err)
Expect(retry.RetryIn).To(BeZero())
})
DescribeTable("caps absurd header values at one hour",
func(header string) {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
}
_, err := client.validateToken(context.Background(), "token")
retry, _ := errors.AsType[*agents.RetryLaterError](err)
Expect(retry.RetryIn).To(Equal(time.Hour))
},
Entry("a large value", "999999"),
Entry("a huge value", "99999999999"),
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
Entry("a value that overflows int64 nanoseconds", "18446744074"),
)
It("maps a body-level 429 sent with a non-429 status", func() {
httpClient.Res = http.Response{
StatusCode: 200,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
}
_, err := client.validateToken(context.Background(), "token")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(7 * time.Second))
})
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
httpClient.Res = http.Response{
StatusCode: 429,
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
}
_, err := client.getArtistUrl(context.Background(), "1")
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
retry, ok := errors.AsType[*agents.RetryLaterError](err)
Expect(ok).To(BeTrue())
Expect(retry.RetryIn).To(Equal(5 * time.Second))
})
})
})

View file

@ -1 +0,0 @@
response-property-enum-value-added INFO

View file

@ -1,155 +0,0 @@
extends: [[spectral:oas, recommended]]
rules:
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
# so the value check below targets `x-module` via `given` instead.
nd-operation-x-module-required:
description: Every operation belongs to exactly one capability module.
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
field: x-module
function: truthy
nd-operation-x-module:
description: Every operation's capability module is one of the known values.
severity: error
given: $.paths[*][get,put,post,delete,patch]['x-module']
then:
function: enumeration
functionOptions:
values:
- core
- streaming
- download
- artwork
- lyrics
- transcoding
- annotations
- playback
- queue
- custom-tags
- grouping
- playlists
- smart-playlists
- sync
- events
- jukebox
- sharing
- radio
- admin
nd-operation-stability-level-required:
description: Every operation declares its stability level, which the breaking-change gate relies on.
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
field: x-stability-level
function: truthy
nd-operation-stability-level:
description: Every operation's stability level is alpha, beta, or stable.
severity: error
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
then:
function: enumeration
functionOptions:
values:
- alpha
- beta
- stable
nd-operation-required-fields:
description: Operations need a stable operationId, summary, description and tags.
severity: error
given: $.paths[*][get,put,post,delete,patch]
then:
- field: operationId
function: truthy
- field: summary
function: truthy
- field: description
function: truthy
- field: tags
function: truthy
# Our schemas live in path/response files, not root components, so this
# walks every resolved `properties` map in the document via `$..` instead.
nd-schema-property-descriptions:
description: Every schema property is documented.
severity: error
given: $..properties[*]
then:
field: description
function: truthy
# patternProperties covers the full 4xx/5xx range; needs an explicit
# `properties` entry too, or `additionalProperties: false` rejects it.
nd-error-responses-are-problems:
description: 4xx and 5xx responses use application/problem+json.
severity: error
given: $.paths[*][*].responses
then:
function: schema
functionOptions:
forceValidationOnCurrentNode: true
schema:
type: object
patternProperties:
"^[45][0-9][0-9]$":
type: object
required: [content]
properties:
content:
type: object
properties:
application/problem+json: {}
required: [application/problem+json]
additionalProperties: false
# Same filter limitation applies here: "is this a list endpoint" is expressed
# as a JSON Schema if/then on the operation object instead of a `given` filter.
nd-list-endpoints-paginate:
description: List endpoints declare the shared offset and limit parameters.
severity: error
given: $.paths[*].get
then:
function: schema
functionOptions:
forceValidationOnCurrentNode: true
schema:
type: object
if:
required: [responses]
properties:
responses:
type: object
required: ['200']
properties:
'200':
type: object
required: [content]
properties:
content:
type: object
required: [application/json]
properties:
application/json:
type: object
required: [schema]
properties:
schema:
type: object
required: [properties]
properties:
properties:
type: object
required: [items]
then:
required: [parameters]
properties:
parameters:
type: array
allOf:
- contains:
type: object
properties:
name:
const: offset
- contains:
type: object
properties:
name:
const: limit

View file

@ -1,262 +0,0 @@
{
"openapi": "3.0.3",
"info": {
"title": "Navidrome API",
"version": "1.0.0",
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
"license": {
"name": "GPL-3.0",
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
}
},
"servers": [
{
"url": "/api/v1"
}
],
"tags": [
{
"name": "server",
"description": "Server discovery and the published OpenAPI document."
}
],
"paths": {
"/server": {
"get": {
"operationId": "getServerInfo",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "Describe the server",
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
"responses": {
"200": {
"description": "Server description.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerInfo"
}
}
}
},
"500": {
"$ref": "#/components/responses/InternalError"
}
}
}
},
"/openapi.json": {
"get": {
"operationId": "getOpenAPISpecJSON",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "Get the OpenAPI document (JSON)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
"200": {
"description": "The OpenAPI document.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
},
"content": {
"application/json": {
"schema": {
"type": "object",
"description": "OpenAPI 3.0 document."
}
}
}
},
"304": {
"$ref": "#/components/responses/NotModified"
}
}
}
},
"/openapi.yaml": {
"get": {
"operationId": "getOpenAPISpecYAML",
"x-module": "core",
"x-stability-level": "alpha",
"tags": [
"server"
],
"summary": "Get the OpenAPI document (YAML)",
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
"responses": {
"200": {
"description": "The OpenAPI document.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
},
"content": {
"application/yaml": {
"schema": {
"type": "object",
"description": "OpenAPI 3.0 document."
}
}
}
},
"304": {
"$ref": "#/components/responses/NotModified"
}
}
}
}
},
"components": {
"securitySchemes": {
"bearerAuth": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT",
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
}
},
"schemas": {
"ServerInfo": {
"type": "object",
"description": "Public server description. Everything an add-server screen needs before login.",
"required": [
"name",
"serverVersion",
"specVersion",
"setupRequired",
"loginMethods"
],
"properties": {
"name": {
"type": "string",
"description": "Human-readable server product name."
},
"serverVersion": {
"type": "string",
"description": "Version of the running server build."
},
"specVersion": {
"type": "string",
"description": "Version of the OpenAPI document this server implements."
},
"setupRequired": {
"type": "boolean",
"description": "True until the first admin user has been created."
},
"loginMethods": {
"type": "array",
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
"items": {
"type": "string",
"enum": [
"password"
]
}
}
}
},
"Problem": {
"type": "object",
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
"required": [
"title",
"status",
"code"
],
"properties": {
"type": {
"type": "string",
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
},
"title": {
"type": "string",
"description": "Short human-readable summary, the same for all occurrences of this problem type."
},
"status": {
"type": "integer",
"description": "HTTP status code of this response."
},
"detail": {
"type": "string",
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
},
"code": {
"type": "string",
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
"enum": [
"validation",
"unauthorized",
"forbidden",
"not_found",
"method_not_allowed",
"unavailable",
"internal"
]
},
"errors": {
"type": "array",
"description": "Per-field failures. Present only when `code` is `validation`.",
"items": {
"$ref": "#/components/schemas/ValidationError"
}
}
}
},
"ValidationError": {
"type": "object",
"description": "One field-level validation failure.",
"required": [
"field",
"message"
],
"properties": {
"field": {
"type": "string",
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
},
"message": {
"type": "string",
"description": "Why the value was rejected."
}
}
}
},
"responses": {
"InternalError": {
"description": "Unexpected server failure. Details are in the server log.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/Problem"
}
}
}
},
"NotModified": {
"description": "Not modified.",
"headers": {
"ETag": {
"$ref": "#/components/headers/ETag"
}
}
}
},
"headers": {
"ETag": {
"description": "Entity tag for `If-None-Match` revalidation.",
"schema": {
"type": "string"
}
}
}
}
}

View file

@ -1,194 +0,0 @@
openapi: 3.0.3
info:
title: Navidrome API
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
Every operation declares `x-stability-level`: `alpha` operations may change or
disappear without notice, `beta` and `stable` operations only change additively.
A level is only ever raised, never lowered.
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
servers:
- url: /api/v1
tags:
- name: server
description: Server discovery and the published OpenAPI document.
paths:
/server:
get:
operationId: getServerInfo
x-module: core
x-stability-level: alpha
tags: [server]
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
responses:
'200':
description: Server description.
content:
application/json:
schema:
$ref: '#/components/schemas/ServerInfo'
'500':
$ref: '#/components/responses/InternalError'
/openapi.json:
get:
operationId: getOpenAPISpecJSON
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: '#/components/headers/ETag'
content:
application/json:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
/openapi.yaml:
get:
operationId: getOpenAPISpecYAML
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: '#/components/headers/ETag'
content:
application/yaml:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: '#/components/responses/NotModified'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
schemas:
ServerInfo:
type: object
description: Public server description. Everything an add-server screen needs before login.
required:
- name
- serverVersion
- specVersion
- setupRequired
- loginMethods
properties:
name:
type: string
description: Human-readable server product name.
serverVersion:
type: string
description: Version of the running server build.
specVersion:
type: string
description: Version of the OpenAPI document this server implements.
setupRequired:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum:
- password
Problem:
type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
required:
- title
- status
- code
properties:
type:
type: string
description: |
URI reference identifying the problem type. Omitted while the problem carries no semantics
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
own semantics get their own URI; switch on `code` instead.
title:
type: string
description: Short human-readable summary, the same for all occurrences of this problem type.
status:
type: integer
description: HTTP status code of this response.
detail:
type: string
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
enum:
- validation
- unauthorized
- forbidden
- not_found
- method_not_allowed
- unavailable
- internal
errors:
type: array
description: Per-field failures. Present only when `code` is `validation`.
items:
$ref: '#/components/schemas/ValidationError'
ValidationError:
type: object
description: One field-level validation failure.
required:
- field
- message
properties:
field:
type: string
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
message:
type: string
description: Why the value was rejected.
responses:
InternalError:
description: Unexpected server failure. Details are in the server log.
content:
application/problem+json:
schema:
$ref: '#/components/schemas/Problem'
NotModified:
description: Not modified.
headers:
ETag:
$ref: '#/components/headers/ETag'
headers:
ETag:
description: Entity tag for `If-None-Match` revalidation.
schema:
type: string

View file

@ -1,35 +0,0 @@
package api
import (
_ "embed"
"encoding/json"
"sync"
)
//go:embed bundled/openapi.json
var specJSON []byte
//go:embed bundled/openapi.yaml
var specYAML []byte
func SpecJSON() []byte {
return specJSON
}
func SpecYAML() []byte {
return specYAML
}
var specVersion = sync.OnceValue(func() string {
var doc struct {
Info struct {
Version string `json:"version"`
} `json:"info"`
}
_ = json.Unmarshal(SpecJSON(), &doc)
return doc.Info.Version
})
func SpecVersion() string {
return specVersion()
}

View file

@ -1,37 +0,0 @@
package api_test
import (
"os"
"github.com/getkin/kin-openapi/openapi3"
"github.com/navidrome/navidrome/api"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
var _ = Describe("Bundled spec", func() {
It("embeds a valid OpenAPI 3 document", func() {
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
Expect(err).ToNot(HaveOccurred())
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
})
It("embeds the YAML variant", func() {
var doc map[string]any
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
Expect(doc).To(HaveKey("paths"))
})
It("reports the version from the bundle, matching the source root document", func() {
src, err := os.ReadFile("api/openapi/openapi.yaml")
Expect(err).ToNot(HaveOccurred())
var root struct {
Info struct{ Version string } `yaml:"info"`
}
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
Expect(api.SpecVersion()).ToNot(BeEmpty())
})
})

View file

@ -1,3 +0,0 @@
description: Entity tag for `If-None-Match` revalidation.
schema:
type: string

View file

@ -1,9 +0,0 @@
name: limit
in: query
description: Maximum number of items to return.
required: false
schema:
type: integer
minimum: 1
maximum: 2000
default: 100

View file

@ -1,5 +0,0 @@
description: The request is malformed or fails validation.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,5 +0,0 @@
description: The caller is authenticated but not allowed to do this.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,5 +0,0 @@
description: Unexpected server failure. Details are in the server log.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,5 +0,0 @@
description: No such resource or endpoint.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,4 +0,0 @@
description: Not modified.
headers:
ETag:
$ref: ../headers/ETag.yaml

View file

@ -1,5 +0,0 @@
description: Missing, invalid, or expired credentials.
content:
application/problem+json:
schema:
$ref: ../schemas/Problem.yaml

View file

@ -1,13 +0,0 @@
type: object
description: Pagination metadata carried by every list response.
required: [total, offset, limit]
properties:
total:
type: integer
description: Total number of items matching the request, ignoring pagination.
offset:
type: integer
description: Zero-based index of the first returned item.
limit:
type: integer
description: Maximum number of items in this page.

View file

@ -1,35 +0,0 @@
type: object
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
required: [title, status, code]
properties:
type:
type: string
description: |
URI reference identifying the problem type. Omitted while the problem carries no semantics
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
own semantics get their own URI; switch on `code` instead.
title:
type: string
description: Short human-readable summary, the same for all occurrences of this problem type.
status:
type: integer
description: HTTP status code of this response.
detail:
type: string
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
code:
type: string
description: Machine-readable error code, and the value clients switch on. New codes may be added.
enum:
- validation
- unauthorized
- forbidden
- not_found
- method_not_allowed
- unavailable
- internal
errors:
type: array
description: Per-field failures. Present only when `code` is `validation`.
items:
$ref: ./ValidationError.yaml

View file

@ -1,22 +0,0 @@
type: object
description: Public server description. Everything an add-server screen needs before login.
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
properties:
name:
type: string
description: Human-readable server product name.
serverVersion:
type: string
description: Version of the running server build.
specVersion:
type: string
description: Version of the OpenAPI document this server implements.
setupRequired:
type: boolean
description: True until the first admin user has been created.
loginMethods:
type: array
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
items:
type: string
enum: [password]

View file

@ -1,10 +0,0 @@
type: object
description: One field-level validation failure.
required: [field, message]
properties:
field:
type: string
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
message:
type: string
description: Why the value was rejected.

View file

@ -1,39 +0,0 @@
openapi: 3.0.3
info:
title: Navidrome API
version: 1.0.0
description: |
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
capability modules through `GET /server` and never sniff versions.
Enums are open: new values may be added to any enum within v1. Clients must
accept values they do not recognise instead of failing.
Every operation declares `x-stability-level`: `alpha` operations may change or
disappear without notice, `beta` and `stable` operations only change additively.
A level is only ever raised, never lowered.
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
in its `Allow` header.
license:
name: GPL-3.0
url: https://www.gnu.org/licenses/gpl-3.0.html
servers:
- url: /api/v1
tags:
- name: server
description: Server discovery and the published OpenAPI document.
paths:
/server:
$ref: ./paths/server.yaml
/openapi.json:
$ref: ./paths/openapi.yaml#/json
/openapi.yaml:
$ref: ./paths/openapi.yaml#/yaml
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: Short-lived access token minted from a device grant. Not yet applied to any operation.

View file

@ -1,42 +0,0 @@
json:
get:
operationId: getOpenAPISpecJSON
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (JSON)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: ../components/headers/ETag.yaml
content:
application/json:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: ../components/responses/NotModified.yaml
yaml:
get:
operationId: getOpenAPISpecYAML
x-module: core
x-stability-level: alpha
tags: [server]
summary: Get the OpenAPI document (YAML)
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
responses:
'200':
description: The OpenAPI document.
headers:
ETag:
$ref: ../components/headers/ETag.yaml
content:
application/yaml:
schema:
type: object
description: OpenAPI 3.0 document.
'304':
$ref: ../components/responses/NotModified.yaml

View file

@ -1,19 +0,0 @@
get:
operationId: getServerInfo
x-module: core
x-stability-level: alpha
tags: [server]
summary: Describe the server
description: |
Returns the public server description. No authentication required.
Authenticated requests will additionally receive the implemented capability modules
once authentication is available.
responses:
'200':
description: Server description.
content:
application/json:
schema:
$ref: ../components/schemas/ServerInfo.yaml
'500':
$ref: ../components/responses/InternalError.yaml

28
api/parameters/_index.yml Normal file
View file

@ -0,0 +1,28 @@
pageOffset:
$ref: './query/pageOffset.yml'
pageLimit:
$ref: './query/pageLimit.yml'
filterEquals:
$ref: './query/filterEquals.yml'
filterLessThan:
$ref: './query/filterLessThan.yml'
filterLessOrEqual:
$ref: './query/filterLessOrEqual.yml'
filterGreaterThan:
$ref: './query/filterGreaterThan.yml'
filterGreaterOrEqual:
$ref: './query/filterGreaterOrEqual.yml'
filterContains:
$ref: './query/filterContains.yml'
filterStartsWith:
$ref: './query/filterStartsWith.yml'
filterEndsWith:
$ref: './query/filterEndsWith.yml'
sort:
$ref: './query/sort.yml'
include:
$ref: './query/include.yml'
includeForTracks:
$ref: './query/includeForTracks.yml'
includeForAlbums:
$ref: './query/includeForAlbums.yml'

View file

@ -0,0 +1,9 @@
name: filter[contains]
in: query
description: 'Filter by any property containing text. Usage: filter[contains]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\w+'

View file

@ -0,0 +1,9 @@
name: filter[endsWith]
in: query
description: 'Filter by any property that ends with text. Usage: filter[endsWith]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\w+'

View file

@ -0,0 +1,9 @@
name: filter[equals]
in: query
description: 'Filter by any property with an exact match. Usage: filter[equals]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\w+'

View file

@ -0,0 +1,9 @@
name: filter[greaterOrEqual]
in: query
description: 'Filter by any numeric property greater than or equal to a value. Usage: filter[greaterOrEqual]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\d+'

View file

@ -0,0 +1,9 @@
name: filter[greaterThan]
in: query
description: 'Filter by any numeric property greater than a value. Usage: filter[greaterThan]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\d+'

View file

@ -0,0 +1,9 @@
name: filter[lessOrEqual]
in: query
description: 'Filter by any numeric property less than or equal to a value. Usage: filter[lessOrEqual]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\d+'

View file

@ -0,0 +1,9 @@
name: filter[lessThan]
in: query
description: 'Filter by any numeric property less than a value. Usage: filter[lessThan]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\d+'

View file

@ -0,0 +1,9 @@
name: filter[startsWith]
in: query
description: 'Filter by any property that starts with text. Usage: filter[startsWith]=property:value'
required: false
schema:
type: array
items:
type: string
pattern: '^\w+:\w+'

View file

@ -0,0 +1,6 @@
name: include
in: query
description: Related resources to include in the response, separated by commas
required: false
schema:
type: string

View file

@ -0,0 +1,13 @@
name: include
in: query
description: Related resources to include in the response, separated by commas
required: false
explode: false
schema:
type: array
x-go-type: includeSlice
items:
type: string
enum:
- track
- artist

View file

@ -0,0 +1,12 @@
name: include
in: query
description: Related resources to include in the response, separated by commas
required: false
explode: false
schema:
type: array
x-go-type: includeSlice
items:
type: string
enum:
- artist

View file

@ -0,0 +1,13 @@
name: include
in: query
description: Related resources to include in the response, separated by commas
required: false
explode: false
schema:
type: array
x-go-type: includeSlice
items:
type: string
enum:
- album
- artist

View file

@ -0,0 +1,9 @@
name: page[limit]
in: query
description: The number of items per page
required: false
schema:
type: integer
format: int32
minimum: 0
default: 10

View file

@ -1,8 +1,9 @@
name: offset
name: page[offset]
in: query
description: Zero-based index of the first item to return.
description: The offset for pagination
required: false
schema:
type: integer
format: int32
minimum: 0
default: 0

View file

@ -0,0 +1,6 @@
name: sort
in: query
description: Sort the results by one or more properties, separated by commas. Prefix the property with '-' for descending order.
required: false
schema:
type: string

33
api/resources/album.yml Normal file
View file

@ -0,0 +1,33 @@
get:
summary: Retrieve an individual album
operationId: getAlbum
parameters:
- $ref: '../parameters/query/includeForAlbum.yml'
- name: albumId
in: path
description: The unique identifier of the album
required: true
schema:
type: string
responses:
'200':
description: An album object
content:
application/vnd.api+json:
schema:
type: object
required: [data]
properties:
data:
$ref: '../schemas/Album.yml'
included:
description: Included resources, as requested by the `include` query parameter
type: array
items:
$ref: '../schemas/IncludedResource.yml'
'403':
$ref: '../responses/NotAuthorized.yml'
'404':
$ref: '../responses/NotFound.yml'
'500':
$ref: '../responses/InternalServerError.yml'

44
api/resources/albums.yml Normal file
View file

@ -0,0 +1,44 @@
get:
summary: Retrieve a list of albums
operationId: getAlbums
parameters:
- $ref: '../parameters/query/pageLimit.yml'
- $ref: '../parameters/query/pageOffset.yml'
- $ref: '../parameters/query/filterEquals.yml'
- $ref: '../parameters/query/filterContains.yml'
- $ref: '../parameters/query/filterLessThan.yml'
- $ref: '../parameters/query/filterLessOrEqual.yml'
- $ref: '../parameters/query/filterGreaterThan.yml'
- $ref: '../parameters/query/filterGreaterOrEqual.yml'
- $ref: '../parameters/query/filterStartsWith.yml'
- $ref: '../parameters/query/filterEndsWith.yml'
- $ref: '../parameters/query/sort.yml'
- $ref: '../parameters/query/includeForAlbums.yml'
responses:
'200':
description: A list of albums
content:
application/vnd.api+json:
schema:
type: object
required: [data, links]
properties:
data:
type: array
items:
$ref: '../schemas/Album.yml'
links:
$ref: '../schemas/PaginationLinks.yml'
meta:
$ref: '../schemas/PaginationMeta.yml'
included:
description: Included resources, as requested by the `include` query parameter
type: array
items:
$ref: '../schemas/IncludedResource.yml'
'400':
$ref: '../responses/BadRequest.yml'
'403':
$ref: '../responses/NotAuthorized.yml'
'500':
$ref: '../responses/InternalServerError.yml'

28
api/resources/artist.yml Normal file
View file

@ -0,0 +1,28 @@
get:
summary: Retrieve an individual artist
operationId: getArtist
parameters:
- $ref: '../parameters/query/include.yml'
- name: artistId
in: path
description: The unique identifier of the artist
required: true
schema:
type: string
responses:
'200':
description: An artist object
content:
application/vnd.api+json:
schema:
type: object
required: [data]
properties:
data:
$ref: '../schemas/Artist.yml'
'403':
$ref: '../responses/NotAuthorized.yml'
'404':
$ref: '../responses/NotFound.yml'
'500':
$ref: '../responses/InternalServerError.yml'

39
api/resources/artists.yml Normal file
View file

@ -0,0 +1,39 @@
get:
summary: Retrieve a list of artists
operationId: getArtists
parameters:
- $ref: '../parameters/query/pageLimit.yml'
- $ref: '../parameters/query/pageOffset.yml'
- $ref: '../parameters/query/filterEquals.yml'
- $ref: '../parameters/query/filterContains.yml'
- $ref: '../parameters/query/filterLessThan.yml'
- $ref: '../parameters/query/filterLessOrEqual.yml'
- $ref: '../parameters/query/filterGreaterThan.yml'
- $ref: '../parameters/query/filterGreaterOrEqual.yml'
- $ref: '../parameters/query/filterStartsWith.yml'
- $ref: '../parameters/query/filterEndsWith.yml'
- $ref: '../parameters/query/sort.yml'
- $ref: '../parameters/query/include.yml'
responses:
'200':
description: A list of artists
content:
application/vnd.api+json:
schema:
type: object
required: [data, links]
properties:
data:
type: array
items:
$ref: '../schemas/Artist.yml'
links:
$ref: '../schemas/PaginationLinks.yml'
meta:
$ref: '../schemas/PaginationMeta.yml'
'400':
$ref: '../responses/BadRequest.yml'
'403':
$ref: '../responses/NotAuthorized.yml'
'500':
$ref: '../responses/InternalServerError.yml'

18
api/resources/server.yml Normal file
View file

@ -0,0 +1,18 @@
get:
summary: Get server's global info
operationId: getServerInfo
responses:
'200':
description: The response’s data key maps to a resource object dictionary representing the server.
content:
application/vnd.api+json:
schema:
type: object
required: [data]
properties:
data:
$ref: '../schemas/ServerInfo.yml'
'403':
$ref: '../responses/NotAuthorized.yml'
'500':
$ref: '../responses/InternalServerError.yml'

33
api/resources/track.yml Normal file
View file

@ -0,0 +1,33 @@
get:
summary: Retrieve an individual track
operationId: getTrack
parameters:
- $ref: '../parameters/query/includeForTracks.yml'
- name: trackId
in: path
description: The unique identifier of the track
required: true
schema:
type: string
responses:
'200':
description: A track object
content:
application/vnd.api+json:
schema:
type: object
required: [data]
properties:
data:
$ref: '../schemas/Track.yml'
included:
description: Included resources, as requested by the `include` query parameter
type: array
items:
$ref: '../schemas/IncludedResource.yml'
'403':
$ref: '../responses/NotAuthorized.yml'
'404':
$ref: '../responses/NotFound.yml'
'500':
$ref: '../responses/InternalServerError.yml'

44
api/resources/tracks.yml Normal file
View file

@ -0,0 +1,44 @@
get:
summary: Retrieve a list of tracks
operationId: getTracks
parameters:
- $ref: '../parameters/query/pageLimit.yml'
- $ref: '../parameters/query/pageOffset.yml'
- $ref: '../parameters/query/filterEquals.yml'
- $ref: '../parameters/query/filterContains.yml'
- $ref: '../parameters/query/filterLessThan.yml'
- $ref: '../parameters/query/filterLessOrEqual.yml'
- $ref: '../parameters/query/filterGreaterThan.yml'
- $ref: '../parameters/query/filterGreaterOrEqual.yml'
- $ref: '../parameters/query/filterStartsWith.yml'
- $ref: '../parameters/query/filterEndsWith.yml'
- $ref: '../parameters/query/sort.yml'
- $ref: '../parameters/query/includeForTracks.yml'
responses:
'200':
description: A list of tracks
content:
application/vnd.api+json:
schema:
type: object
required: [data, links]
properties:
data:
type: array
items:
$ref: '../schemas/Track.yml'
links:
$ref: '../schemas/PaginationLinks.yml'
meta:
$ref: '../schemas/PaginationMeta.yml'
included:
description: Included resources, as requested by the `include` query parameter
type: array
items:
$ref: '../schemas/IncludedResource.yml'
'400':
$ref: '../responses/BadRequest.yml'
'403':
$ref: '../responses/NotAuthorized.yml'
'500':
$ref: '../responses/InternalServerError.yml'

View file

@ -0,0 +1,5 @@
description: Bad Request
content:
application/vnd.api+json:
schema:
$ref: '../schemas/ErrorList.yml'

View file

@ -0,0 +1,5 @@
description: Internal Server Error
content:
application/vnd.api+json:
schema:
$ref: '../schemas/ErrorList.yml'

View file

@ -0,0 +1,5 @@
description: Not Authorized
content:
application/vnd.api+json:
schema:
$ref: '../schemas/ErrorList.yml'

View file

@ -0,0 +1,5 @@
description: Not Found
content:
application/vnd.api+json:
schema:
$ref: '../schemas/ErrorList.yml'

8
api/responses/_index.yml Normal file
View file

@ -0,0 +1,8 @@
NotFound:
$ref: './NotFound.yml'
NotAuthorized:
$ref: './NotAuthorized.yml'
BadRequest:
$ref: './BadRequest.yml'
InternalServerError:
$ref: './InternalServerError.yml'

20
api/schemas/Album.yml Normal file
View file

@ -0,0 +1,20 @@
allOf:
- $ref: './ResourceObject.yml'
- type: object
properties:
attributes:
$ref: './AlbumAttributes.yml'
relationships:
type: object
properties:
artists:
type: array
items:
$ref: './AlbumArtistRelationship.yml'
tracks:
type: array
items:
$ref: './AlbumTrackRelationship.yml'
required:
- artists
- tracks

View file

@ -0,0 +1,9 @@
type: object
properties:
meta:
$ref: './ArtistMetaObject.yml'
data:
$ref: './ResourceObject.yml'
required:
- meta
- data

View file

@ -0,0 +1,23 @@
type: object
properties:
title:
type: string
description: The title of the album
artist:
type: string
description: The artist of the album
releaseDate:
type: string
description: The release date of the album
tracktotal:
type: integer
description: The number of tracks on the album
disctotal:
type: integer
description: The number of discs in the album
genre:
type: string
description: The genre of the album
required:
- title
- artist

Some files were not shown because too many files have changed in this diff Show more