mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-09 10:57:08 +02:00
Compare commits
10 commits
master
...
custom-col
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b1830513 | ||
|
|
eca4c5acf0 |
||
|
|
e766a5d780 | ||
|
|
90d6cd5f47 | ||
|
|
24ab04581a | ||
|
|
8e647a0e41 | ||
|
|
86c326bd4a |
||
|
|
929e7193b4 | ||
|
|
9bcefea0ca | ||
|
|
b0cb40b029 |
1563 changed files with 30233 additions and 147069 deletions
|
|
@ -13,5 +13,17 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& apt-get -y install --no-install-recommends ffmpeg
|
&& apt-get -y install --no-install-recommends ffmpeg
|
||||||
|
|
||||||
|
# Install TagLib from cross-taglib releases
|
||||||
|
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
||||||
|
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
||||||
|
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
|
||||||
|
&& mv /usr/include/taglib/* /usr/include/ \
|
||||||
|
&& rmdir /usr/include/taglib \
|
||||||
|
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
||||||
|
|
||||||
|
ENV CGO_CFLAGS_ALLOW="--define-prefix"
|
||||||
|
|
||||||
# [Optional] Uncomment this line to install global node packages.
|
# [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
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
"dockerfile": "Dockerfile",
|
"dockerfile": "Dockerfile",
|
||||||
"args": {
|
"args": {
|
||||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||||
"VARIANT": "1.27",
|
"VARIANT": "1.25",
|
||||||
// Options
|
// Options
|
||||||
"INSTALL_NODE": "true",
|
"INSTALL_NODE": "true",
|
||||||
"NODE_VERSION": "v24"
|
"NODE_VERSION": "v24",
|
||||||
|
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspaceMount": "",
|
"workspaceMount": "",
|
||||||
|
|
|
||||||
6
.github/FUNDING.yml
vendored
6
.github/FUNDING.yml
vendored
|
|
@ -1,10 +1,10 @@
|
||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
ko_fi: deluan
|
|
||||||
github: deluan
|
github: deluan
|
||||||
open_collective: navidrome
|
|
||||||
liberapay: deluan
|
|
||||||
patreon: # Replace with a single Patreon username
|
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
|
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
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
issuehunt: # Replace with a single IssueHunt username
|
issuehunt: # Replace with a single IssueHunt username
|
||||||
|
|
|
||||||
23
.github/actions/download-taglib/action.yml
vendored
Normal file
23
.github/actions/download-taglib/action.yml
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
name: 'Download TagLib'
|
||||||
|
description: 'Downloads and extracts the TagLib library, adding it to PKG_CONFIG_PATH'
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: 'Version of TagLib to download'
|
||||||
|
required: true
|
||||||
|
platform:
|
||||||
|
description: 'Platform to download TagLib for'
|
||||||
|
default: 'linux-amd64'
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Download TagLib
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/taglib
|
||||||
|
cd /tmp
|
||||||
|
FILE=taglib-${{ inputs.platform }}.tar.gz
|
||||||
|
wget https://github.com/navidrome/cross-taglib/releases/download/v${{ inputs.version }}/${FILE}
|
||||||
|
tar -xzf ${FILE} -C taglib
|
||||||
|
PKG_CONFIG_PREFIX=/tmp/taglib
|
||||||
|
echo "PKG_CONFIG_PREFIX=${PKG_CONFIG_PREFIX}" >> $GITHUB_ENV
|
||||||
|
echo "PKG_CONFIG_PATH=${PKG_CONFIG_PATH}:${PKG_CONFIG_PREFIX}/lib/pkgconfig" >> $GITHUB_ENV
|
||||||
14
.github/actions/prepare-docker/action.yml
vendored
14
.github/actions/prepare-docker/action.yml
vendored
|
|
@ -53,13 +53,13 @@ runs:
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: inputs.hub_username != '' && inputs.hub_password != ''
|
if: inputs.hub_username != '' && inputs.hub_password != ''
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
username: ${{ inputs.hub_username }}
|
username: ${{ inputs.hub_username }}
|
||||||
password: ${{ inputs.hub_password }}
|
password: ${{ inputs.hub_password }}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v4
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|
@ -67,18 +67,12 @@ runs:
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
id: buildx
|
id: buildx
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v3
|
||||||
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
|
- name: Extract metadata for Docker image
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
github-token: ${{ inputs.github_token }}
|
|
||||||
labels: |
|
labels: |
|
||||||
maintainer=deluan@navidrome.org
|
maintainer=deluan@navidrome.org
|
||||||
images: |
|
images: |
|
||||||
|
|
|
||||||
60
.github/workflows/coverage-on-pr.yml
vendored
60
.github/workflows/coverage-on-pr.yml
vendored
|
|
@ -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 }}
|
|
||||||
24
.github/workflows/download-link-on-pr.yml
vendored
24
.github/workflows/download-link-on-pr.yml
vendored
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v9
|
- uses: actions/github-script@v3
|
||||||
with:
|
with:
|
||||||
# This snippet is public-domain, taken from
|
# This snippet is public-domain, taken from
|
||||||
# https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml
|
# 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 pull_user_id = ${{github.event.sender.id}};
|
||||||
|
|
||||||
const issue_number = await (async () => {
|
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) {
|
for (const pull of data) {
|
||||||
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
|
if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) {
|
||||||
return pull.number;
|
return pull.number;
|
||||||
|
|
@ -33,24 +34,21 @@ jobs:
|
||||||
return core.error(`No matching pull request found`);
|
return core.error(`No matching pull request found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||||
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
|
if (!artifacts.length) {
|
||||||
if (!downloadable.length) {
|
|
||||||
return core.error(`No artifacts found`);
|
return core.error(`No artifacts found`);
|
||||||
}
|
}
|
||||||
const header = `Download the artifacts for this pull request:`;
|
let body = `Download the artifacts for this pull request:\n`;
|
||||||
let body = `${header}\n`;
|
for (const art of artifacts) {
|
||||||
for (const art of downloadable) {
|
|
||||||
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
|
const {data: comments} = await github.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]');
|
||||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
|
|
||||||
if (existing_comment) {
|
if (existing_comment) {
|
||||||
core.info(`Updating comment ${existing_comment.id}`);
|
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 {
|
} else {
|
||||||
core.info(`Creating a comment`);
|
core.info(`Creating a comment`);
|
||||||
await github.rest.issues.createComment({repo, owner, issue_number, body});
|
await github.issues.createComment({repo, owner, issue_number, body});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
298
.github/workflows/pipeline.yml
vendored
298
.github/workflows/pipeline.yml
vendored
|
|
@ -14,6 +14,8 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
CROSS_TAGLIB_VERSION: "2.1.1-2"
|
||||||
|
CGO_CFLAGS_ALLOW: "--define-prefix"
|
||||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -24,7 +26,7 @@ jobs:
|
||||||
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
|
||||||
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
@ -32,7 +34,7 @@ jobs:
|
||||||
- name: Show git version info
|
- name: Show git version info
|
||||||
run: |
|
run: |
|
||||||
echo "git describe (dirty): $(git describe --dirty --always --tags)"
|
echo "git describe (dirty): $(git describe --dirty --always --tags)"
|
||||||
echo "git describe --tags --abbrev=0: $(git describe --tags --abbrev=0)"
|
echo "git describe --tags: $(git describe --tags `git rev-list --tags --max-count=1`)"
|
||||||
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
|
echo "git tag: $(git tag --sort=-committerdate | head -n 1)"
|
||||||
echo "github_ref: $GITHUB_REF"
|
echo "github_ref: $GITHUB_REF"
|
||||||
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
|
echo "github_head_sha: ${{ github.event.pull_request.head.sha }}"
|
||||||
|
|
@ -40,7 +42,7 @@ jobs:
|
||||||
- name: Determine git current SHA and latest tag
|
- name: Determine git current SHA and latest tag
|
||||||
id: git-version
|
id: git-version
|
||||||
run: |
|
run: |
|
||||||
GIT_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
|
GIT_TAG=$(git tag --sort=-committerdate | head -n 1)
|
||||||
if [ -n "$GIT_TAG" ]; then
|
if [ -n "$GIT_TAG" ]; then
|
||||||
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
if [[ "$GITHUB_REF" != refs/tags/* ]]; then
|
||||||
GIT_TAG=${GIT_TAG}-SNAPSHOT
|
GIT_TAG=${GIT_TAG}-SNAPSHOT
|
||||||
|
|
@ -62,22 +64,17 @@ jobs:
|
||||||
name: Lint Go code
|
name: Lint Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- name: Download TagLib
|
||||||
|
uses: ./.github/actions/download-taglib
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
|
|
||||||
# Keep CI on the same version `make lint` installs, so a clean local run
|
|
||||||
# cannot turn red in CI just because a new golangci-lint was released.
|
|
||||||
- name: Resolve golangci-lint version
|
|
||||||
id: golangci-version
|
|
||||||
run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
uses: golangci/golangci-lint-action@v9
|
||||||
with:
|
with:
|
||||||
version: ${{ steps.golangci-version.outputs.version }}
|
version: latest
|
||||||
problem-matchers: true
|
problem-matchers: true
|
||||||
args: --timeout 2m
|
args: --timeout 2m
|
||||||
|
|
||||||
|
|
@ -92,23 +89,8 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
|
||||||
with:
|
|
||||||
path: bin
|
|
||||||
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
|
||||||
|
|
||||||
- name: Lint OpenAPI spec
|
|
||||||
run: make api-lint
|
|
||||||
|
|
||||||
- name: Run go generate
|
- name: Run go generate
|
||||||
run: |
|
run: go generate ./...
|
||||||
make api-gen
|
|
||||||
go generate ./...
|
|
||||||
- name: Verify no changes from go generate
|
- name: Verify no changes from go generate
|
||||||
run: |
|
run: |
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
|
|
@ -117,51 +99,25 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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'
|
|
||||||
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
|
|
||||||
|
|
||||||
go:
|
go:
|
||||||
name: Test Go code
|
name: Test Go code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out code into the Go module directory
|
- name: Check out code into the Go module directory
|
||||||
uses: actions/checkout@v7
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
- name: Download TagLib
|
||||||
|
uses: ./.github/actions/download-taglib
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
version: ${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
|
|
||||||
- name: Download dependencies
|
- name: Download dependencies
|
||||||
run: go mod download
|
run: go mod download
|
||||||
|
|
||||||
# Name must stay unique across the workflow: octocov matches step names
|
- name: Test
|
||||||
# by name across every job, and waits for each match to finish.
|
run: |
|
||||||
- name: Test with coverage
|
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
|
go test -shuffle=on -tags netgo -race ./... -v
|
||||||
|
|
||||||
- name: Test ndpgen
|
- name: Test ndpgen
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -170,164 +126,13 @@ jobs:
|
||||||
go build -o ndpgen .
|
go build -o ndpgen .
|
||||||
./ndpgen --help
|
./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
|
|
||||||
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
|
|
||||||
|
|
||||||
js:
|
js:
|
||||||
name: Test JS code
|
name: Test JS code
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: "--max_old_space_size=4096"
|
NODE_OPTIONS: "--max_old_space_size=4096"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 24
|
node-version: 24
|
||||||
|
|
@ -358,7 +163,7 @@ jobs:
|
||||||
name: Lint i18n files
|
name: Lint i18n files
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- run: |
|
- run: |
|
||||||
set -e
|
set -e
|
||||||
for file in resources/i18n/*.json; do
|
for file in resources/i18n/*.json; do
|
||||||
|
|
@ -385,7 +190,7 @@ jobs:
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
needs: [js, go, go-lint, i18n-lint, git-version, check-push-enabled]
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
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 ]
|
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 ]
|
||||||
|
|
@ -404,7 +209,7 @@ jobs:
|
||||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Prepare Docker Buildx
|
- name: Prepare Docker Buildx
|
||||||
uses: ./.github/actions/prepare-docker
|
uses: ./.github/actions/prepare-docker
|
||||||
|
|
@ -416,7 +221,7 @@ jobs:
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Build Binaries
|
- name: Build Binaries
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|
@ -427,24 +232,10 @@ jobs:
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
- 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
|
- name: Upload Binaries
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: navidrome-${{ env.PLATFORM }}
|
name: navidrome-${{ env.PLATFORM }}
|
||||||
path: ./output
|
path: ./output
|
||||||
|
|
@ -453,7 +244,7 @@ jobs:
|
||||||
- name: Build and push image by digest
|
- name: Build and push image by digest
|
||||||
id: push-image
|
id: push-image
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|
@ -462,6 +253,7 @@ jobs:
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
outputs: |
|
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=${{ 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
|
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
@ -474,7 +266,7 @@ jobs:
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v6
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM }}
|
name: digests-${{ env.PLATFORM }}
|
||||||
|
|
@ -493,10 +285,10 @@ jobs:
|
||||||
env:
|
env:
|
||||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
@ -527,10 +319,10 @@ jobs:
|
||||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
@ -546,7 +338,7 @@ jobs:
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Create manifest list and push to Docker Hub
|
- name: Create manifest list and push to Docker Hub
|
||||||
uses: nick-fields/retry@v4
|
uses: nick-fields/retry@v3
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 5
|
timeout_minutes: 5
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|
@ -570,7 +362,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -580,9 +372,9 @@ jobs:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-windows*
|
pattern: navidrome-windows*
|
||||||
|
|
@ -601,7 +393,7 @@ jobs:
|
||||||
du -h binaries/msi/*.msi
|
du -h binaries/msi/*.msi
|
||||||
|
|
||||||
- name: Upload MSI files
|
- name: Upload MSI files
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: navidrome-windows-installers
|
name: navidrome-windows-installers
|
||||||
path: binaries/msi/*.msi
|
path: binaries/msi/*.msi
|
||||||
|
|
@ -614,12 +406,12 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-*
|
pattern: navidrome-*
|
||||||
|
|
@ -632,9 +424,9 @@ jobs:
|
||||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: '2.16.0'
|
version: '~> v2'
|
||||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
@ -645,7 +437,7 @@ jobs:
|
||||||
rm ./dist/*.tar.gz ./dist/*.zip
|
rm ./dist/*.tar.gz ./dist/*.zip
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: dist/navidrome_0*
|
path: dist/navidrome_0*
|
||||||
|
|
@ -668,13 +460,13 @@ jobs:
|
||||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download all-packages artifact
|
- name: Download all-packages artifact
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: navidrome_linux_${{ matrix.item }}
|
name: navidrome_linux_${{ matrix.item }}
|
||||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||||
|
|
|
||||||
2
.github/workflows/push-translations.yml
vendored
2
.github/workflows/push-translations.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.repository_owner == 'navidrome' }}
|
if: ${{ github.repository_owner == 'navidrome' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
|
|
||||||
|
|
|
||||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
|
|
@ -28,7 +28,7 @@ jobs:
|
||||||
This pull request has been automatically locked since there
|
This pull request has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new issue for related bugs.
|
Please open a new issue for related bugs.
|
||||||
- uses: actions/stale@v10
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
operations-per-run: 999
|
operations-per-run: 999
|
||||||
days-before-issue-stale: 180
|
days-before-issue-stale: 180
|
||||||
|
|
|
||||||
2
.github/workflows/update-translations.yml
vendored
2
.github/workflows/update-translations.yml
vendored
|
|
@ -8,7 +8,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: ${{ github.repository_owner == 'navidrome' }}
|
if: ${{ github.repository_owner == 'navidrome' }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
- name: Get updated translations
|
- name: Get updated translations
|
||||||
id: poeditor
|
id: poeditor
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
150
.github/workflows/validate-migrations.sh
vendored
150
.github/workflows/validate-migrations.sh
vendored
|
|
@ -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"
|
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -37,14 +37,4 @@ AGENTS.md
|
||||||
*.wasm
|
*.wasm
|
||||||
*.ndp
|
*.ndp
|
||||||
openspec/
|
openspec/
|
||||||
.agents
|
|
||||||
go.work*
|
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/
|
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,16 @@ version: "2"
|
||||||
run:
|
run:
|
||||||
build-tags:
|
build-tags:
|
||||||
- netgo
|
- netgo
|
||||||
- sqlite_fts5
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- containedctx
|
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
- errorlint
|
- errorlint
|
||||||
- forbidigo
|
|
||||||
- gocritic
|
- gocritic
|
||||||
- gocyclo
|
- gocyclo
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
|
|
@ -28,9 +25,6 @@ linters:
|
||||||
disable:
|
disable:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
settings:
|
settings:
|
||||||
errcheck:
|
|
||||||
exclude-functions:
|
|
||||||
- (*github.com/zeebo/xxh3.Hasher).Write
|
|
||||||
gocritic:
|
gocritic:
|
||||||
disable-all: true
|
disable-all: true
|
||||||
enabled-checks:
|
enabled-checks:
|
||||||
|
|
@ -41,29 +35,10 @@ linters:
|
||||||
- G401
|
- G401
|
||||||
- G505
|
- G505
|
||||||
- G115
|
- 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:
|
govet:
|
||||||
enable:
|
enable:
|
||||||
- nilness
|
- nilness
|
||||||
exclusions:
|
exclusions:
|
||||||
rules:
|
|
||||||
- linters:
|
|
||||||
- gosec
|
|
||||||
path: _test\.go
|
|
||||||
text: "G703"
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- containedctx
|
|
||||||
- path-except: 'db/migrations/'
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
|
|
@ -74,9 +49,6 @@ linters:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
- node_modules
|
|
||||||
- _gen\.go$
|
|
||||||
- .worktrees
|
|
||||||
formatters:
|
formatters:
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
|
|
@ -84,4 +56,3 @@ formatters:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
- node_modules
|
|
||||||
|
|
|
||||||
44
.octocov.yml
44
.octocov.yml
|
|
@ -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}
|
|
||||||
|
|
@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
|
||||||
### Commit Conventions
|
### Commit Conventions
|
||||||
Each commit message must adhere to the following format:
|
Each commit message must adhere to the following format:
|
||||||
```
|
```
|
||||||
<type>(scope): <description>
|
<type>(scope): <description> - <issue number>
|
||||||
|
|
||||||
[optional body]
|
[optional body]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
140
Dockerfile
140
Dockerfile
|
|
@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build xx (original image: tonistiigi/xx)
|
### Build xx (original image: tonistiigi/xx)
|
||||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||||
|
|
||||||
# v1.9.0
|
# v1.9.0
|
||||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||||
|
|
@ -24,9 +24,29 @@ RUN cd /out && \
|
||||||
FROM scratch AS xx
|
FROM scratch AS xx
|
||||||
COPY --from=xx-build /out/ /usr/bin/
|
COPY --from=xx-build /out/ /usr/bin/
|
||||||
|
|
||||||
|
########################################################################################################################
|
||||||
|
### Get TagLib
|
||||||
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS taglib-build
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
ARG CROSS_TAGLIB_VERSION=2.1.1-2
|
||||||
|
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||||
|
|
||||||
|
# wget in busybox can't follow redirects
|
||||||
|
RUN <<EOT
|
||||||
|
apk add --no-cache wget
|
||||||
|
PLATFORM=$(echo ${TARGETPLATFORM} | tr '/' '-')
|
||||||
|
FILE=taglib-${PLATFORM}.tar.gz
|
||||||
|
|
||||||
|
DOWNLOAD_URL=${CROSS_TAGLIB_RELEASES_URL}${FILE}
|
||||||
|
wget ${DOWNLOAD_URL}
|
||||||
|
|
||||||
|
mkdir /taglib
|
||||||
|
tar -xzf ${FILE} -C /taglib
|
||||||
|
EOT
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome UI
|
### Build Navidrome UI
|
||||||
FROM --platform=$BUILDPLATFORM node:lts-alpine AS ui
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/node:lts-alpine AS ui
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install node dependencies
|
# Install node dependencies
|
||||||
|
|
@ -42,50 +62,8 @@ FROM scratch AS ui-bundle
|
||||||
COPY --from=ui /build /build
|
COPY --from=ui /build /build
|
||||||
|
|
||||||
########################################################################################################################
|
########################################################################################################################
|
||||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
### Build Navidrome binary
|
||||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine
|
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-trixie AS base
|
||||||
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
|
RUN apt-get update && apt-get install -y clang lld
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
|
|
@ -110,13 +88,15 @@ RUN --mount=type=bind,source=. \
|
||||||
--mount=from=ui,source=/build,target=./ui/build,ro \
|
--mount=from=ui,source=/build,target=./ui/build,ro \
|
||||||
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
--mount=from=osxcross,src=/osxcross/SDK,target=/xx-sdk,ro \
|
||||||
--mount=type=cache,target=/root/.cache \
|
--mount=type=cache,target=/root/.cache \
|
||||||
--mount=type=cache,target=/go/pkg/mod <<EOT
|
--mount=type=cache,target=/go/pkg/mod \
|
||||||
set -e
|
--mount=from=taglib-build,target=/taglib,src=/taglib,ro <<EOT
|
||||||
|
|
||||||
# Setup CGO cross-compilation environment
|
# Setup CGO cross-compilation environment
|
||||||
xx-go --wrap
|
xx-go --wrap
|
||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
cat "$(go env GOENV)" 2>/dev/null || true
|
export CGO_CFLAGS_ALLOW="--define-prefix"
|
||||||
|
export PKG_CONFIG_PATH=/taglib/lib/pkgconfig
|
||||||
|
cat $(go env GOENV)
|
||||||
|
|
||||||
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
# Only Darwin (macOS) requires clang (default), Windows requires gcc, everything else can use any compiler.
|
||||||
# So let's use gcc for everything except Darwin.
|
# So let's use gcc for everything except Darwin.
|
||||||
|
|
@ -125,25 +105,14 @@ RUN --mount=type=bind,source=. \
|
||||||
export CXX=$(xx-info)-g++
|
export CXX=$(xx-info)-g++
|
||||||
export LD_EXTRA="-extldflags '-static -latomic'"
|
export LD_EXTRA="-extldflags '-static -latomic'"
|
||||||
fi
|
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
|
if [ "$(xx-info os)" = "windows" ]; then
|
||||||
export EXT=".exe"
|
export EXT=".exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BUILD_TAGS=$(./release/build-tags.sh)
|
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
|
||||||
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.gitSha=${GIT_SHA} \
|
||||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||||
-o /out/navidrome${EXT} .
|
-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
|
EOT
|
||||||
|
|
||||||
# Verify if the binary was built for the correct platform and it is statically linked
|
# Verify if the binary was built for the correct platform and it is statically linked
|
||||||
|
|
@ -152,55 +121,17 @@ RUN xx-verify --static /out/navidrome*
|
||||||
FROM scratch AS binary
|
FROM scratch AS binary
|
||||||
COPY --from=build /out /
|
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
|
### Build Final Image
|
||||||
FROM alpine:3.22 AS final
|
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||||
LABEL maintainer="deluan@navidrome.org"
|
LABEL maintainer="deluan@navidrome.org"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||||
|
|
||||||
# Install runtime dependencies
|
# Install ffmpeg and mpv
|
||||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
RUN apk add -U --no-cache ffmpeg mpv sqlite
|
||||||
# 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/
|
# Copy navidrome binary
|
||||||
RUN mpv --no-video --ao=null --version > /dev/null
|
COPY --from=build /out/navidrome /app/
|
||||||
|
|
||||||
# Copy navidrome binary (musl build for Docker, enables native libwebp)
|
|
||||||
COPY --from=build-alpine /out/navidrome /app/
|
|
||||||
|
|
||||||
VOLUME ["/data", "/music"]
|
VOLUME ["/data", "/music"]
|
||||||
ENV ND_MUSICFOLDER=/music
|
ENV ND_MUSICFOLDER=/music
|
||||||
|
|
@ -211,7 +142,6 @@ RUN touch /.nddockerenv
|
||||||
|
|
||||||
EXPOSE ${ND_PORT}
|
EXPOSE ${ND_PORT}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PATH="/app:${PATH}"
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/navidrome"]
|
ENTRYPOINT ["/app/navidrome"]
|
||||||
|
|
||||||
|
|
|
||||||
123
Makefile
123
Makefile
|
|
@ -1,15 +1,13 @@
|
||||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||||
NODE_VERSION=$(shell cat .nvmrc)
|
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
|
# Set global environment variables, required for most targets
|
||||||
|
export CGO_CFLAGS_ALLOW=--define-prefix
|
||||||
export ND_ENABLEINSIGHTSCOLLECTOR=false
|
export ND_ENABLEINSIGHTSCOLLECTOR=false
|
||||||
|
|
||||||
ifneq ("$(wildcard .git/HEAD)","")
|
ifneq ("$(wildcard .git/HEAD)","")
|
||||||
GIT_SHA=$(shell git rev-parse --short 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`)-SNAPSHOT
|
||||||
else
|
else
|
||||||
GIT_SHA=source_archive
|
GIT_SHA=source_archive
|
||||||
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
GIT_TAG=$(patsubst navidrome-%,v%,$(notdir $(PWD)))-SNAPSHOT
|
||||||
|
|
@ -20,11 +18,9 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
DOCKER_TAG ?= deluan/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||||
VACUUM_VERSION ?= v0.30.6
|
CROSS_TAGLIB_VERSION ?= 2.1.1-2
|
||||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
GOLANGCI_LINT_VERSION ?= v2.10.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/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
|
|
@ -50,12 +46,12 @@ stop: ##@Development Stop development servers (UI and backend)
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||||
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
|
go tool ginkgo watch -tags=netgo -notify ./...
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
|
|
||||||
PKG ?= ./...
|
PKG ?= ./...
|
||||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
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)
|
go test -tags netgo $(PKG)
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
||||||
|
|
@ -66,7 +62,7 @@ testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
|
||||||
.PHONY: testall
|
.PHONY: testall
|
||||||
|
|
||||||
test-race: ##@Development Run Go tests with race detector
|
test-race: ##@Development Run Go tests with race detector
|
||||||
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
|
go test -tags netgo -race -shuffle=on $(PKG)
|
||||||
.PHONY: test-race
|
.PHONY: test-race
|
||||||
|
|
||||||
test-js: ##@Development Run JS tests
|
test-js: ##@Development Run JS tests
|
||||||
|
|
@ -79,8 +75,8 @@ test-i18n: ##@Development Validate all translations files
|
||||||
|
|
||||||
install-golangci-lint: ##@Development Install golangci-lint if not present
|
install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
@INSTALL=false; \
|
@INSTALL=false; \
|
||||||
if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \
|
if PATH=$$PATH:./bin 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); \
|
CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \
|
||||||
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \
|
||||||
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \
|
||||||
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \
|
||||||
|
|
@ -96,47 +92,8 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
fi
|
fi
|
||||||
.PHONY: install-golangci-lint
|
.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
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
PATH=$$PATH:./bin golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lintall: lint ##@Development Lint Go and JS code
|
lintall: lint ##@Development Lint Go and JS code
|
||||||
|
|
@ -151,16 +108,15 @@ format: ##@Development Format code
|
||||||
.PHONY: format
|
.PHONY: format
|
||||||
|
|
||||||
wire: check_go_env ##@Development Update Dependency Injection
|
wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags=netgo ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
gen: check_go_env ##@Development Run go generate for code generation
|
||||||
go generate ./...
|
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
|
||||||
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 -python -rust
|
||||||
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
|
||||||
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
|
||||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
|
|
||||||
go mod tidy -C plugins/pdk/go
|
go mod tidy -C plugins/pdk/go
|
||||||
.PHONY: gen
|
.PHONY: gen
|
||||||
|
|
||||||
|
|
@ -188,14 +144,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||||
.PHONY: setup-git
|
.PHONY: setup-git
|
||||||
|
|
||||||
build: check_go_env buildjs ##@Build Build the project
|
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)
|
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
buildall: deprecated build
|
buildall: deprecated build
|
||||||
.PHONY: buildall
|
.PHONY: buildall
|
||||||
|
|
||||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
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)
|
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: debug-build
|
.PHONY: debug-build
|
||||||
|
|
||||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||||
|
|
@ -220,6 +176,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
||||||
--platform $(PLATFORMS) \
|
--platform $(PLATFORMS) \
|
||||||
--build-arg GIT_TAG=${GIT_TAG} \
|
--build-arg GIT_TAG=${GIT_TAG} \
|
||||||
--build-arg GIT_SHA=${GIT_SHA} \
|
--build-arg GIT_SHA=${GIT_SHA} \
|
||||||
|
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||||
--output "./binaries" --target binary .
|
--output "./binaries" --target binary .
|
||||||
.PHONY: docker-build
|
.PHONY: docker-build
|
||||||
|
|
||||||
|
|
@ -231,6 +188,7 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
|
||||||
--platform $(IMAGE_PLATFORMS) \
|
--platform $(IMAGE_PLATFORMS) \
|
||||||
--build-arg GIT_TAG=${GIT_TAG} \
|
--build-arg GIT_TAG=${GIT_TAG} \
|
||||||
--build-arg GIT_SHA=${GIT_SHA} \
|
--build-arg GIT_SHA=${GIT_SHA} \
|
||||||
|
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||||
--tag $(DOCKER_TAG) .
|
--tag $(DOCKER_TAG) .
|
||||||
.PHONY: docker-image
|
.PHONY: docker-image
|
||||||
|
|
||||||
|
|
@ -243,8 +201,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
||||||
@du -h binaries/msi/*.msi
|
@du -h binaries/msi/*.msi
|
||||||
.PHONY: docker-msi
|
.PHONY: docker-msi
|
||||||
|
|
||||||
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
||||||
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
||||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||||
if [ -f navidrome.toml ]; then \
|
if [ -f navidrome.toml ]; then \
|
||||||
|
|
@ -255,7 +213,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
|
||||||
fi; \
|
fi; \
|
||||||
fi; \
|
fi; \
|
||||||
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
||||||
.PHONY: docker-run
|
.PHONY: run-docker
|
||||||
|
|
||||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
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
|
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||||
|
|
@ -274,39 +232,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
||||||
.PHONY: get-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
|
#### Miscellaneous
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@ 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
|
- **Multi-platform**, runs on macOS, Linux and Windows. **Docker** images are also provided
|
||||||
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
- Ready to use binaries for all major platforms, including **Raspberry Pi**
|
||||||
- Automatically **monitors your library** for changes, importing new files and reloading new metadata
|
- 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)
|
- **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)
|
- **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**
|
- **Transcoding** on the fly. Can be set per user/player. **Opus encoding is supported**
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package deezer
|
package deezer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
bytes "bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -13,26 +13,15 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/microcosm-cc/bluemonday"
|
"github.com/microcosm-cc/bluemonday"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiBaseURL = "https://api.deezer.com"
|
const apiBaseURL = "https://api.deezer.com"
|
||||||
const authBaseURL = "https://auth.deezer.com"
|
const authBaseURL = "https://auth.deezer.com"
|
||||||
|
|
||||||
// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200
|
var (
|
||||||
// and no rate-limit headers, so the body code is the only signal.
|
ErrNotFound = errors.New("deezer: not found")
|
||||||
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 {
|
type httpDoer interface {
|
||||||
Do(req *http.Request) (*http.Response, error)
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
|
@ -67,7 +56,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results.Data) == 0 {
|
if len(results.Data) == 0 {
|
||||||
return nil, agents.ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return results.Data, nil
|
return results.Data, nil
|
||||||
}
|
}
|
||||||
|
|
@ -85,31 +74,20 @@ func (c *client) makeRequest(req *http.Request, response any) error {
|
||||||
return err
|
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 {
|
if resp.StatusCode != 200 {
|
||||||
return fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
|
return c.parseError(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.Unmarshal(data, response)
|
return json.Unmarshal(data, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseBodyError returns the error Deezer reported in the body, or nil when it reported none.
|
func (c *client) parseError(data []byte) error {
|
||||||
func parseBodyError(data []byte) error {
|
var deezerError Error
|
||||||
var body errorResponse
|
err := json.Unmarshal(data, &deezerError)
|
||||||
// Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer.
|
if err != nil {
|
||||||
_ = json.Unmarshal(data, &body)
|
return err
|
||||||
switch {
|
|
||||||
case body.Error == nil:
|
|
||||||
return nil
|
|
||||||
case body.Error.Code == errCodeQuota:
|
|
||||||
return errors.Join(body.Error, agents.ErrRetryLater)
|
|
||||||
default:
|
|
||||||
return body.Error
|
|
||||||
}
|
}
|
||||||
|
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
|
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -84,8 +84,8 @@ func (c *client) getJWT(ctx context.Context) (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
// Calculate TTL with a 1-minute buffer for clock skew and network delays
|
||||||
expiresAt, ok := token.Expiration()
|
expiresAt := token.Expiration()
|
||||||
if !ok || expiresAt.IsZero() {
|
if expiresAt.IsZero() {
|
||||||
return "", errors.New("deezer: JWT token has no expiration time")
|
return "", errors.New("deezer: JWT token has no expiration time")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
"github.com/lestrrat-go/jwx/v2/jwt"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -179,8 +179,7 @@ var _ = Describe("JWT Authentication", func() {
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
// Verify token has no expiration
|
// Verify token has no expiration
|
||||||
_, hasExp := testToken.Expiration()
|
Expect(testToken.Expiration().IsZero()).To(BeTrue())
|
||||||
Expect(hasExp).To(BeFalse())
|
|
||||||
|
|
||||||
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,12 @@ package deezer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -43,37 +41,7 @@ var _ = Describe("client", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
Expect(err).To(MatchError(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")
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package deezer
|
package deezer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -14,7 +13,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -36,7 +34,9 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
languages: conf.Server.Deezer.Languages,
|
languages: conf.Server.Deezer.Languages,
|
||||||
}
|
}
|
||||||
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
httpClient := &http.Client{
|
||||||
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
|
}
|
||||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||||
agent.client = newClient(cachedHttpClient)
|
agent.client = newClient(cachedHttpClient)
|
||||||
return agent
|
return agent
|
||||||
|
|
@ -68,29 +68,21 @@ func (s *deezerAgent) GetArtistImages(ctx context.Context, _, name, _ string) ([
|
||||||
{artist.PictureSmall, deezerApiPictureSmallSize},
|
{artist.PictureSmall, deezerApiPictureSmallSize},
|
||||||
}
|
}
|
||||||
for _, imgData := range possibleImages {
|
for _, imgData := range possibleImages {
|
||||||
if imgData.URL != "" && !isPlaceholderPicture(imgData.URL) {
|
if imgData.URL != "" {
|
||||||
res = append(res, agents.ExternalImage{
|
res = append(res, agents.ExternalImage{
|
||||||
URL: imgData.URL,
|
URL: imgData.URL,
|
||||||
Size: imgData.Size,
|
Size: imgData.Size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(res) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
return res, nil
|
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) {
|
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||||
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
||||||
|
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
|
||||||
|
return nil, agents.ErrNotFound
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -103,32 +95,13 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deezer's RANKING order isn't reliable for homonyms: rank name matches
|
// If the first one has the same name, that's the one
|
||||||
// ahead of non-matches, prefer an exact-case match, then the most fans.
|
if !strings.EqualFold(artists[0].Name, name) {
|
||||||
rank := func(a Artist) int {
|
log.Trace(ctx, "Top artist do not match", "searched_name", name, "found_name", artists[0].Name)
|
||||||
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
|
return nil, agents.ErrNotFound
|
||||||
}
|
}
|
||||||
log.Trace(ctx, "Found artist", "name", best.Name, "id", best.ID, "link", best.Link, "nb_fan", best.NbFan)
|
log.Trace(ctx, "Found artist", "name", artists[0].Name, "id", artists[0].ID, "link", artists[0].Link)
|
||||||
return new(best), nil
|
return &artists[0], err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package deezer
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -35,130 +34,6 @@ var _ = Describe("deezerAgent", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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() {
|
Describe("GetArtistBiography - Language Fallback", func() {
|
||||||
var agent *deezerAgent
|
var agent *deezerAgent
|
||||||
var httpClient *langAwareHttpClient
|
var httpClient *langAwareHttpClient
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,12 @@ type Artist struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type errorResponse struct {
|
type Error struct {
|
||||||
Error *deezerError `json:"error"`
|
Error struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelatedArtists struct {
|
type RelatedArtists struct {
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ var _ = Describe("Responses", func() {
|
||||||
|
|
||||||
Describe("Error", func() {
|
Describe("Error", func() {
|
||||||
It("parses the error response correctly", func() {
|
It("parses the error response correctly", func() {
|
||||||
var errorResp errorResponse
|
var errorResp Error
|
||||||
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
|
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
|
||||||
err := json.Unmarshal(body, &errorResp)
|
err := json.Unmarshal(body, &errorResp)
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/djherbis/times"
|
"github.com/djherbis/times"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
"github.com/navidrome/navidrome/utils/gg"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -90,7 +91,8 @@ var _ = Describe("Extractor", func() {
|
||||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
metadata := metadata.New(path, info)
|
metadata := metadata.New(path, info)
|
||||||
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
mf := metadata.ToMediaFile(1, "folderID")
|
||||||
|
return &mf
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
@ -107,7 +109,7 @@ var _ = Describe("Extractor", func() {
|
||||||
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||||
},
|
},
|
||||||
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
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)),
|
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -118,8 +120,8 @@ var _ = Describe("Extractor", func() {
|
||||||
DisplayTitle: "",
|
DisplayTitle: "",
|
||||||
Lang: code,
|
Lang: code,
|
||||||
Line: []model.Line{
|
Line: []model.Line{
|
||||||
{Start: new(int64(0)), Value: "This is"},
|
{Start: gg.P(int64(0)), Value: "This is"},
|
||||||
{Start: new(int64(2500)), Value: secondLine},
|
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||||
},
|
},
|
||||||
Offset: nil,
|
Offset: nil,
|
||||||
Synced: true,
|
Synced: true,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/storage/local"
|
"github.com/navidrome/navidrome/core/storage/local"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
|
@ -44,34 +43,10 @@ func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e extractor) Version() string {
|
func (e extractor) Version() string {
|
||||||
bi, ok := debug.ReadBuildInfo()
|
return "go-taglib (TagLib 2.1.1 WASM)"
|
||||||
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) {
|
func (e extractor) extractMetadata(filePath string) (*metadata.Info, 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)
|
f, close, err := e.openFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
||||||
|
|
@ -90,7 +65,6 @@ func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err er
|
||||||
Channels: int(props.Channels),
|
Channels: int(props.Channels),
|
||||||
SampleRate: int(props.SampleRate),
|
SampleRate: int(props.SampleRate),
|
||||||
BitDepth: int(props.BitsPerSample),
|
BitDepth: int(props.BitsPerSample),
|
||||||
Codec: props.Codec,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
||||||
|
|
@ -125,6 +99,16 @@ func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err er
|
||||||
// openFile opens the file at filePath using the extractor's filesystem.
|
// openFile opens the file at filePath using the extractor's filesystem.
|
||||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
// 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) {
|
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
||||||
|
// Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory
|
||||||
|
// on hardened systems like NixOS with MemoryDenyWriteExecute=true)
|
||||||
|
debug.SetPanicOnFault(true)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r)
|
||||||
|
err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Open the file from the filesystem
|
// Open the file from the filesystem
|
||||||
file, err := e.fs.Open(filePath)
|
file, err := e.fs.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -295,7 +279,4 @@ func init() {
|
||||||
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
||||||
return &extractor{fsys}
|
return &extractor{fsys}
|
||||||
})
|
})
|
||||||
conf.AddHook(func() {
|
|
||||||
log.Debug("go-taglib version", "version", extractor{}.Version())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -128,17 +127,6 @@ var _ = Describe("Extractor", func() {
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
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(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
|
|
@ -214,7 +202,6 @@ var _ = Describe("Extractor", func() {
|
||||||
// Only run permission tests if we are not root
|
// Only run permission tests if we are not root
|
||||||
RegularUserContext("when run without root privileges", func() {
|
RegularUserContext("when run without root privileges", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
tests.SkipOnWindows("uses Unix file permission bits")
|
|
||||||
// Use root fs for absolute paths in temp directory
|
// Use root fs for absolute paths in temp directory
|
||||||
e = &extractor{fs: os.DirFS("/")}
|
e = &extractor{fs: os.DirFS("/")}
|
||||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -60,7 +59,9 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||||
secret: conf.Server.LastFM.Secret,
|
secret: conf.Server.LastFM.Secret,
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := &http.Client{
|
||||||
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
|
}
|
||||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
l.httpClient = chc
|
l.httpClient = chc
|
||||||
l.client = newClient(l.apiKey, l.secret, chc)
|
l.client = newClient(l.apiKey, l.secret, chc)
|
||||||
|
|
@ -92,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
||||||
var resp agents.AlbumInfo
|
var resp agents.AlbumInfo
|
||||||
for _, lang := range l.languages {
|
for _, lang := range l.languages {
|
||||||
var err error
|
var err error
|
||||||
a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
|
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -113,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||||
a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0])
|
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -232,7 +233,8 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
||||||
res = append(res, agents.Song{
|
res = append(res, agents.Song{
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
MBID: t.MBID,
|
MBID: t.MBID,
|
||||||
Artists: []agents.Artist{{Name: t.Artist.Name, MBID: t.Artist.MBID}},
|
Artist: t.Artist.Name,
|
||||||
|
ArtistMBID: t.Artist.MBID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
|
|
@ -241,10 +243,6 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
||||||
var (
|
var (
|
||||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
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) {
|
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||||
|
|
@ -271,9 +269,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
||||||
var res []agents.ExternalImage
|
var res []agents.ExternalImage
|
||||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||||
if n == nil {
|
if n == nil {
|
||||||
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
|
return res, nil
|
||||||
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 {
|
for _, attr := range n.Attr {
|
||||||
if attr.Key != "content" {
|
if attr.Key != "content" {
|
||||||
|
|
@ -291,18 +287,22 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable —
|
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
|
||||||
// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed.
|
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
|
||||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
|
var lfErr *lastFMError
|
||||||
a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
|
isLastFMError := errors.As(err, &lfErr)
|
||||||
if err != nil {
|
|
||||||
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
|
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
|
||||||
// A not-found is a definitive absence, not a fault: return the shared sentinel so the
|
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||||
// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
|
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
|
||||||
log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
|
}
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
|
if err != nil {
|
||||||
|
if isLastFMError && lfErr.Code == 6 {
|
||||||
|
log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err)
|
||||||
|
} else {
|
||||||
|
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err)
|
||||||
}
|
}
|
||||||
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
|
|
@ -314,12 +314,6 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s
|
||||||
|
|
||||||
a, err := l.client.artistGetInfo(ctx, name, lang)
|
a, err := l.client.artistGetInfo(ctx, name, lang)
|
||||||
if err != nil {
|
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)
|
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -411,8 +405,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
||||||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
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 {
|
||||||
if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) {
|
|
||||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||||
}
|
}
|
||||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||||
|
|
@ -423,10 +416,6 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
||||||
return err == nil && sk != ""
|
return err == nil && sk != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface {
|
||||||
|
|
|
||||||
|
|
@ -100,15 +100,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
Expect(httpClient.RequestCount).To(Equal(1))
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
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("Language Fallback", func() {
|
||||||
|
|
@ -318,11 +309,11 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
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{
|
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: "Dreaming of Me", MBID: "027b553e-7c74-3ed4-a95e-1d4fea51f174", Artist: "Depeche Mode", ArtistMBID: "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: "Everything Counts", MBID: "5a5a3ca4-bdb8-4641-a674-9b54b9b319a6", Artist: "Depeche Mode", ArtistMBID: "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: "Don't You Want Me", MBID: "", Artist: "The Human League", ArtistMBID: "7adaabfb-acfb-47bc-8c7c-59471c2f0db8"},
|
||||||
{Name: "Tainted Love", MBID: "", Artists: []agents.Artist{{Name: "Soft Cell", MBID: "7fb50287-029d-47cc-825a-235ca28024b2"}}},
|
{Name: "Tainted Love", MBID: "", Artist: "Soft Cell", ArtistMBID: "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"}}},
|
{Name: "Blue Monday", MBID: "727e84c6-1b56-31dd-a958-a5f46305cec0", Artist: "New Order", ArtistMBID: "f1106b17-dcbb-45f6-b938-199ccfab50cc"},
|
||||||
}))
|
}))
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
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("track")).To(Equal("Just Can't Get Enough"))
|
||||||
|
|
@ -357,7 +348,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
var track *model.MediaFile
|
var track *model.MediaFile
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
|
|
@ -506,16 +497,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
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() {
|
It("returns ErrRetryLater on http errors", func() {
|
||||||
httpClient.Res = http.Response{
|
httpClient.Res = http.Response{
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
||||||
|
|
@ -558,10 +539,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
URL: "https://www.last.fm/music/Cher/Believe",
|
URL: "https://www.last.fm/music/Cher/Believe",
|
||||||
}))
|
}))
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
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(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
|
||||||
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() {
|
It("returns empty images if no images are available", func() {
|
||||||
|
|
@ -580,7 +558,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
Expect(httpClient.RequestCount).To(Equal(1))
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns an error if Last.fm call returns an error", func() {
|
It("returns an error if Last.fm call returns an error", func() {
|
||||||
|
|
@ -588,19 +566,25 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
Expect(httpClient.RequestCount).To(Equal(1))
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
|
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "")
|
||||||
Expect(err).To(HaveOccurred())
|
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.RequestCount).To(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("MBID non existent in Last.fm", func() {
|
||||||
|
It("calls again when last.fm returns an error 6", func() {
|
||||||
|
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||||
|
_, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||||
|
Expect(httpClient.RequestCount).To(Equal(2))
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("GetArtistImages", func() {
|
Describe("GetArtistImages", func() {
|
||||||
var agent *lastfmAgent
|
var agent *lastfmAgent
|
||||||
|
|
@ -629,13 +613,6 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
|
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() {
|
It("returns empty list if image is the ignored default image", func() {
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||||
|
|
@ -648,41 +625,18 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
Expect(images).To(BeEmpty())
|
Expect(images).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("errors when the page has no meta tags", func() {
|
It("returns empty list if page has no meta tags", func() {
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
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", "")
|
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||||
Expect(err).To(MatchError(errNoArtistPage))
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(images).To(BeEmpty())
|
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() {
|
It("returns error if API call fails", func() {
|
||||||
apiClient.Err = errors.New("api error")
|
apiClient.Err = errors.New("api error")
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -42,7 +41,9 @@ func NewRouter(ds model.DataStore) *Router {
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := &http.Client{
|
||||||
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
|
}
|
||||||
r.client = newClient(r.apiKey, r.secret, hc)
|
r.client = newClient(r.apiKey, r.secret, hc)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
@ -76,13 +77,6 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp["status"] = key != ""
|
resp["status"] = key != ""
|
||||||
linkToken, err := createLinkToken(u.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err)
|
|
||||||
_ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp["linkToken"] = linkToken
|
|
||||||
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
_ = rest.RespondWithJSON(w, http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -103,17 +97,11 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
_ = rest.RespondWithError(w, http.StatusBadRequest, "token not received")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
linkToken, err := p.String("uid")
|
uid, err := p.String("uid")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
_ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uid, err := verifyLinkToken(linkToken)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err)
|
|
||||||
_ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
// Need to add user to context, as this is a non-authenticated endpoint, so it does not
|
||||||
// automatically contain any user info
|
// automatically contain any user info
|
||||||
|
|
@ -132,7 +120,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||||
sessionKey, err := s.client.getSession(ctx, token)
|
sessionKey, err := s.client.getSession(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
|
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||||
"requestId", middleware.GetReqID(ctx), err)
|
"requestId", middleware.GetReqID(ctx), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -15,15 +14,11 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
|
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
|
||||||
// errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200
|
|
||||||
// and no rate-limit headers, so the body code is the only signal.
|
|
||||||
errCodeRateLimit = 29
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type lastFMError struct {
|
type lastFMError struct {
|
||||||
|
|
@ -49,12 +44,6 @@ type client struct {
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space.
|
|
||||||
// album.getInfo decodes only once, so it must not use this.
|
|
||||||
func escapePlus(s string) string {
|
|
||||||
return strings.ReplaceAll(s, "+", "%2B")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "album.getInfo")
|
params.Add("method", "album.getInfo")
|
||||||
|
|
@ -72,7 +61,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
|
||||||
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getInfo")
|
params.Add("method", "artist.getInfo")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("lang", lang)
|
params.Add("lang", lang)
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -84,7 +73,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*
|
||||||
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getSimilar")
|
params.Add("method", "artist.getSimilar")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -96,7 +85,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (
|
||||||
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getTopTracks")
|
params.Add("method", "artist.getTopTracks")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -108,8 +97,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
|
||||||
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "track.getSimilar")
|
params.Add("method", "track.getSimilar")
|
||||||
params.Add("track", escapePlus(name))
|
params.Add("track", name)
|
||||||
params.Add("artist", escapePlus(artist))
|
params.Add("artist", artist)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -236,11 +225,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
||||||
return nil, jsonErr
|
return nil, jsonErr
|
||||||
}
|
}
|
||||||
if response.Error != 0 {
|
if response.Error != 0 {
|
||||||
var err error = &lastFMError{Code: response.Error, Message: response.Message}
|
return &response, &lastFMError{Code: response.Error, Message: response.Message}
|
||||||
if response.Error == errCodeRateLimit {
|
|
||||||
err = errors.Join(err, &agents.RetryLaterError{})
|
|
||||||
}
|
|
||||||
return &response, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &response, nil
|
return &response, nil
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(album.Name).To(Equal("Believe"))
|
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"))
|
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() {
|
Describe("artistGetInfo", func() {
|
||||||
|
|
@ -57,15 +48,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
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() {
|
It("fails if Last.fm returns an http status != 200", func() {
|
||||||
httpClient.Res = http.Response{
|
httpClient.Res = http.Response{
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
||||||
|
|
@ -125,15 +107,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(len(similar.Artists)).To(Equal(2))
|
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"))
|
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() {
|
Describe("artistGetTopTracks", func() {
|
||||||
|
|
@ -146,15 +119,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(len(top.Track)).To(Equal(2))
|
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"))
|
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() {
|
Describe("trackGetSimilar", func() {
|
||||||
|
|
@ -171,17 +135,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
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() {
|
It("returns empty list when no similar tracks found", func() {
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -3,6 +3,7 @@ package listenbrainz
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
|
@ -11,7 +12,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -33,7 +33,9 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||||
}
|
}
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := &http.Client{
|
||||||
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
|
}
|
||||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
l.client = newClient(l.baseURL, chc)
|
l.client = newClient(l.baseURL, chc)
|
||||||
return l
|
return l
|
||||||
|
|
@ -139,10 +141,16 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam
|
||||||
|
|
||||||
res := make([]agents.Song, len(resp))
|
res := make([]agents.Song, len(resp))
|
||||||
for i, t := range resp {
|
for i, t := range resp {
|
||||||
|
mbid := ""
|
||||||
|
if len(t.ArtistMBIDs) > 0 {
|
||||||
|
mbid = t.ArtistMBIDs[0]
|
||||||
|
}
|
||||||
|
|
||||||
res[i] = agents.Song{
|
res[i] = agents.Song{
|
||||||
Album: t.ReleaseName,
|
Album: t.ReleaseName,
|
||||||
AlbumMBID: t.ReleaseMBID,
|
AlbumMBID: t.ReleaseMBID,
|
||||||
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
|
Artist: t.ArtistName,
|
||||||
|
ArtistMBID: mbid,
|
||||||
Duration: t.DurationMs,
|
Duration: t.DurationMs,
|
||||||
Name: t.RecordingName,
|
Name: t.RecordingName,
|
||||||
MBID: t.RecordingMbid,
|
MBID: t.RecordingMbid,
|
||||||
|
|
@ -151,25 +159,6 @@ func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistNam
|
||||||
return res, nil
|
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) {
|
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
|
||||||
if mbid == "" {
|
if mbid == "" {
|
||||||
return nil, agents.ErrNotFound
|
return nil, agents.ErrNotFound
|
||||||
|
|
@ -214,7 +203,7 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
|
||||||
songs[i] = agents.Song{
|
songs[i] = agents.Song{
|
||||||
Album: song.ReleaseName,
|
Album: song.ReleaseName,
|
||||||
AlbumMBID: song.ReleaseMBID,
|
AlbumMBID: song.ReleaseMBID,
|
||||||
Artists: []agents.Artist{{Name: song.Artist}},
|
Artist: song.Artist,
|
||||||
MBID: song.MBID,
|
MBID: song.MBID,
|
||||||
Name: song.Name,
|
Name: song.Name,
|
||||||
}
|
}
|
||||||
|
|
@ -223,10 +212,6 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin
|
||||||
return songs, nil
|
return songs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server.ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
agent = listenBrainzConstructor(ds)
|
agent = listenBrainzConstructor(ds)
|
||||||
agent.client = newClient("http://localhost:8080", httpClient)
|
agent.client = newClient("http://localhost:8080", httpClient)
|
||||||
|
|
@ -164,19 +164,6 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
err := agent.Scrobble(ctx, "user-1", sc)
|
||||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
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() {
|
Describe("GetArtistUrl", func() {
|
||||||
|
|
@ -265,7 +252,8 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
ID: "",
|
ID: "",
|
||||||
Name: "world.execute(me);",
|
Name: "world.execute(me);",
|
||||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
Artist: "Mili",
|
||||||
|
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||||
Album: "Miracle Milk",
|
Album: "Miracle Milk",
|
||||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||||
Duration: 211912,
|
Duration: 211912,
|
||||||
|
|
@ -274,7 +262,8 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
ID: "",
|
ID: "",
|
||||||
Name: "String Theocracy",
|
Name: "String Theocracy",
|
||||||
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
Artist: "Mili",
|
||||||
|
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||||
Album: "String Theocracy",
|
Album: "String Theocracy",
|
||||||
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
||||||
Duration: 174000,
|
Duration: 174000,
|
||||||
|
|
@ -292,42 +281,14 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
ID: "",
|
ID: "",
|
||||||
Name: "world.execute(me);",
|
Name: "world.execute(me);",
|
||||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
Artist: "Mili",
|
||||||
|
ArtistMBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56",
|
||||||
Album: "Miracle Milk",
|
Album: "Miracle Milk",
|
||||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
||||||
Duration: 211912,
|
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() {
|
Describe("GetSimilarArtists", func() {
|
||||||
|
|
@ -436,7 +397,8 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
Name: "Take On Me",
|
Name: "Take On Me",
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||||
ISRC: "",
|
ISRC: "",
|
||||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
Artist: "a‐ha",
|
||||||
|
ArtistMBID: "",
|
||||||
Album: "Hunting High and Low",
|
Album: "Hunting High and Low",
|
||||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||||
Duration: 0,
|
Duration: 0,
|
||||||
|
|
@ -446,7 +408,8 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
Name: "Wake Me Up Before You Go‐Go",
|
Name: "Wake Me Up Before You Go‐Go",
|
||||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
||||||
ISRC: "",
|
ISRC: "",
|
||||||
Artists: []agents.Artist{{Name: "Wham!"}},
|
Artist: "Wham!",
|
||||||
|
ArtistMBID: "",
|
||||||
Album: "Make It Big",
|
Album: "Make It Big",
|
||||||
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
||||||
Duration: 0,
|
Duration: 0,
|
||||||
|
|
@ -468,7 +431,8 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
Name: "Take On Me",
|
Name: "Take On Me",
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
||||||
ISRC: "",
|
ISRC: "",
|
||||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
Artist: "a‐ha",
|
||||||
|
ArtistMBID: "",
|
||||||
Album: "Hunting High and Low",
|
Album: "Hunting High and Low",
|
||||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
||||||
Duration: 0,
|
Duration: 0,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type sessionKeysRepo interface {
|
type sessionKeysRepo interface {
|
||||||
|
|
@ -38,7 +37,9 @@ func NewRouter(ds model.DataStore) *Router {
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := &http.Client{
|
||||||
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
|
}
|
||||||
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,12 +21,6 @@ const (
|
||||||
labsBase = "https://labs.api.listenbrainz.org/"
|
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 (
|
var (
|
||||||
ErrorNotFound = errors.New("listenbrainz: not found")
|
ErrorNotFound = errors.New("listenbrainz: not found")
|
||||||
)
|
)
|
||||||
|
|
@ -181,9 +174,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
||||||
}
|
}
|
||||||
|
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
return nil, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
decoder := json.NewDecoder(resp.Body)
|
||||||
|
|
||||||
var response listenBrainzResponse
|
var response listenBrainzResponse
|
||||||
|
|
@ -195,10 +185,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
||||||
return nil, jsonErr
|
return nil, jsonErr
|
||||||
}
|
}
|
||||||
if response.Code != 0 && response.Code != 200 {
|
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, &listenBrainzError{Code: response.Code, Message: response.Error}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,9 +211,6 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint
|
||||||
// On a 200 code, there is no code. Decode using using error message if it exists
|
// On a 200 code, there is no code. Decode using using error message if it exists
|
||||||
if resp.StatusCode != 200 {
|
if resp.StatusCode != 200 {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
return nil, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
decoder := json.NewDecoder(resp.Body)
|
||||||
|
|
||||||
var lbzError lbzHttpError
|
var lbzError lbzHttpError
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,13 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -465,73 +461,4 @@ var _ = Describe("client", func() {
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("rate limiting", func() {
|
|
||||||
It("returns RetryLaterError with the header delay on 429", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(3 * time.Second))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns RetryLaterError with zero delay when no header is present", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(retry.RetryIn).To(BeZero())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("caps absurd header values at one hour",
|
|
||||||
func(header string) {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(retry.RetryIn).To(Equal(time.Hour))
|
|
||||||
},
|
|
||||||
Entry("a large value", "999999"),
|
|
||||||
Entry("a huge value", "99999999999"),
|
|
||||||
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
|
|
||||||
Entry("a value that overflows int64 nanoseconds", "18446744074"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("maps a body-level 429 sent with a non-429 status", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "1")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(5 * time.Second))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
116
adapters/spotify/client.go
Normal file
116
adapters/spotify/client.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const apiBaseUrl = "https://api.spotify.com/v1/"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("spotify: not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type httpDoer interface {
|
||||||
|
Do(req *http.Request) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newClient(id, secret string, hc httpDoer) *client {
|
||||||
|
return &client{id, secret, hc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type client struct {
|
||||||
|
id string
|
||||||
|
secret string
|
||||||
|
hc httpDoer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
|
||||||
|
token, err := c.authorize(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := url.Values{}
|
||||||
|
params.Add("type", "artist")
|
||||||
|
params.Add("q", name)
|
||||||
|
params.Add("offset", "0")
|
||||||
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "GET", apiBaseUrl+"search", nil)
|
||||||
|
req.URL.RawQuery = params.Encode()
|
||||||
|
req.Header.Add("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
|
var results SearchResults
|
||||||
|
err = c.makeRequest(req, &results)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results.Artists.Items) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return results.Artists.Items, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) authorize(ctx context.Context) (string, error) {
|
||||||
|
payload := url.Values{}
|
||||||
|
payload.Add("grant_type", "client_credentials")
|
||||||
|
|
||||||
|
encodePayload := payload.Encode()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.spotify.com/api/token", strings.NewReader(encodePayload))
|
||||||
|
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Add("Content-Length", strconv.Itoa(len(encodePayload)))
|
||||||
|
auth := c.id + ":" + c.secret
|
||||||
|
req.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
|
||||||
|
|
||||||
|
response := map[string]any{}
|
||||||
|
err := c.makeRequest(req, &response)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := response["access_token"]; ok {
|
||||||
|
return v.(string), nil
|
||||||
|
}
|
||||||
|
log.Error(ctx, "Invalid spotify response", "resp", response)
|
||||||
|
return "", errors.New("invalid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) makeRequest(req *http.Request, response any) error {
|
||||||
|
log.Trace(req.Context(), fmt.Sprintf("Sending Spotify %s request", req.Method), "url", req.URL)
|
||||||
|
resp, err := c.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer resp.Body.Close()
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return c.parseError(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.Unmarshal(data, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *client) parseError(data []byte) error {
|
||||||
|
var e Error
|
||||||
|
err := json.Unmarshal(data, &e)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("spotify error(%s): %s", e.Code, e.Message)
|
||||||
|
}
|
||||||
131
adapters/spotify/client_test.go
Normal file
131
adapters/spotify/client_test.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("client", func() {
|
||||||
|
var httpClient *fakeHttpClient
|
||||||
|
var client *client
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
httpClient = &fakeHttpClient{}
|
||||||
|
client = newClient("SPOTIFY_ID", "SPOTIFY_SECRET", httpClient)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ArtistImages", func() {
|
||||||
|
It("returns artist images from a successful request", func() {
|
||||||
|
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||||
|
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||||
|
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||||
|
})
|
||||||
|
|
||||||
|
artists, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(artists).To(HaveLen(20))
|
||||||
|
Expect(artists[0].Popularity).To(Equal(82))
|
||||||
|
|
||||||
|
images := artists[0].Images
|
||||||
|
Expect(images).To(HaveLen(3))
|
||||||
|
Expect(images[0].Width).To(Equal(640))
|
||||||
|
Expect(images[1].Width).To(Equal(320))
|
||||||
|
Expect(images[2].Width).To(Equal(160))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails if artist was not found", func() {
|
||||||
|
httpClient.mock("https://api.spotify.com/v1/search", http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(`{
|
||||||
|
"artists" : {
|
||||||
|
"href" : "https://api.spotify.com/v1/search?query=dasdasdas%2Cdna&type=artist&offset=0&limit=20",
|
||||||
|
"items" : [ ], "limit" : 20, "next" : null, "offset" : 0, "previous" : null, "total" : 0
|
||||||
|
}}`)),
|
||||||
|
})
|
||||||
|
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||||
|
Expect(err).To(MatchError(ErrNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails if not able to authorize", func() {
|
||||||
|
f, _ := os.Open("tests/fixtures/spotify.search.artist.json")
|
||||||
|
httpClient.mock("https://api.spotify.com/v1/search", http.Response{Body: f, StatusCode: 200})
|
||||||
|
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||||
|
StatusCode: 400,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := client.searchArtists(context.TODO(), "U2", 10)
|
||||||
|
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("authorize", func() {
|
||||||
|
It("returns an access_token on successful authorization", func() {
|
||||||
|
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(`{"access_token": "NEW_ACCESS_TOKEN","token_type": "Bearer","expires_in": 3600}`)),
|
||||||
|
})
|
||||||
|
|
||||||
|
token, err := client.authorize(context.TODO())
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(token).To(Equal("NEW_ACCESS_TOKEN"))
|
||||||
|
auth := httpClient.lastRequest.Header.Get("Authorization")
|
||||||
|
Expect(auth).To(Equal("Basic U1BPVElGWV9JRDpTUE9USUZZX1NFQ1JFVA=="))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails on unsuccessful authorization", func() {
|
||||||
|
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||||
|
StatusCode: 400,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(`{"error":"invalid_client","error_description":"Invalid client"}`)),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := client.authorize(context.TODO())
|
||||||
|
Expect(err).To(MatchError("spotify error(invalid_client): Invalid client"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("fails on invalid JSON response", func() {
|
||||||
|
httpClient.mock("https://accounts.spotify.com/api/token", http.Response{
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(bytes.NewBufferString(`{NOT_VALID}`)),
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err := client.authorize(context.TODO())
|
||||||
|
Expect(err).To(MatchError("invalid character 'N' looking for beginning of object key string"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
type fakeHttpClient struct {
|
||||||
|
responses map[string]*http.Response
|
||||||
|
lastRequest *http.Request
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeHttpClient) mock(url string, response http.Response) {
|
||||||
|
if c.responses == nil {
|
||||||
|
c.responses = make(map[string]*http.Response)
|
||||||
|
}
|
||||||
|
c.responses[url] = &response
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeHttpClient) Do(req *http.Request) (*http.Response, error) {
|
||||||
|
c.lastRequest = req
|
||||||
|
u := req.URL
|
||||||
|
u.RawQuery = ""
|
||||||
|
if resp, ok := c.responses[u.String()]; ok {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
panic("URL not mocked: " + u.String())
|
||||||
|
}
|
||||||
30
adapters/spotify/responses.go
Normal file
30
adapters/spotify/responses.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package spotify
|
||||||
|
|
||||||
|
type SearchResults struct {
|
||||||
|
Artists ArtistsResult `json:"artists"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArtistsResult struct {
|
||||||
|
HRef string `json:"href"`
|
||||||
|
Items []Artist `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Artist struct {
|
||||||
|
Genres []string `json:"genres"`
|
||||||
|
HRef string `json:"href"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Popularity int `json:"popularity"`
|
||||||
|
Images []Image `json:"images"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code string `json:"error"`
|
||||||
|
Message string `json:"error_description"`
|
||||||
|
}
|
||||||
48
adapters/spotify/responses_test.go
Normal file
48
adapters/spotify/responses_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Responses", func() {
|
||||||
|
Describe("Search type=artist", func() {
|
||||||
|
It("parses the artist search result correctly ", func() {
|
||||||
|
var resp SearchResults
|
||||||
|
body, _ := os.ReadFile("tests/fixtures/spotify.search.artist.json")
|
||||||
|
err := json.Unmarshal(body, &resp)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
|
Expect(resp.Artists.Items).To(HaveLen(20))
|
||||||
|
u2 := resp.Artists.Items[0]
|
||||||
|
Expect(u2.Name).To(Equal("U2"))
|
||||||
|
Expect(u2.Genres).To(ContainElements("irish rock", "permanent wave", "rock"))
|
||||||
|
Expect(u2.ID).To(Equal("51Blml2LZPmy7TTiAg47vQ"))
|
||||||
|
Expect(u2.HRef).To(Equal("https://api.spotify.com/v1/artists/51Blml2LZPmy7TTiAg47vQ"))
|
||||||
|
Expect(u2.Images[0].URL).To(Equal("https://i.scdn.co/image/e22d5c0c8139b8439440a69854ed66efae91112d"))
|
||||||
|
Expect(u2.Images[0].Width).To(Equal(640))
|
||||||
|
Expect(u2.Images[0].Height).To(Equal(640))
|
||||||
|
Expect(u2.Images[1].URL).To(Equal("https://i.scdn.co/image/40d6c5c14355cfc127b70da221233315497ec91d"))
|
||||||
|
Expect(u2.Images[1].Width).To(Equal(320))
|
||||||
|
Expect(u2.Images[1].Height).To(Equal(320))
|
||||||
|
Expect(u2.Images[2].URL).To(Equal("https://i.scdn.co/image/7293d6752ae8a64e34adee5086858e408185b534"))
|
||||||
|
Expect(u2.Images[2].Width).To(Equal(160))
|
||||||
|
Expect(u2.Images[2].Height).To(Equal(160))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Error", func() {
|
||||||
|
It("parses the error response correctly", func() {
|
||||||
|
var errorResp Error
|
||||||
|
body := []byte(`{"error":"invalid_client","error_description":"Invalid client"}`)
|
||||||
|
err := json.Unmarshal(body, &errorResp)
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
|
||||||
|
Expect(errorResp.Code).To(Equal("invalid_client"))
|
||||||
|
Expect(errorResp.Message).To(Equal("Invalid client"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
96
adapters/spotify/spotify.go
Normal file
96
adapters/spotify/spotify.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package spotify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/utils/cache"
|
||||||
|
"github.com/xrash/smetrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
const spotifyAgentName = "spotify"
|
||||||
|
|
||||||
|
type spotifyAgent struct {
|
||||||
|
ds model.DataStore
|
||||||
|
id string
|
||||||
|
secret string
|
||||||
|
client *client
|
||||||
|
}
|
||||||
|
|
||||||
|
func spotifyConstructor(ds model.DataStore) agents.Interface {
|
||||||
|
if conf.Server.Spotify.ID == "" || conf.Server.Spotify.Secret == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
l := &spotifyAgent{
|
||||||
|
ds: ds,
|
||||||
|
id: conf.Server.Spotify.ID,
|
||||||
|
secret: conf.Server.Spotify.Secret,
|
||||||
|
}
|
||||||
|
hc := &http.Client{
|
||||||
|
Timeout: consts.DefaultHttpClientTimeOut,
|
||||||
|
}
|
||||||
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
|
l.client = newClient(l.id, l.secret, chc)
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotifyAgent) AgentName() string {
|
||||||
|
return spotifyAgentName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotifyAgent) GetArtistImages(ctx context.Context, id, name, mbid string) ([]agents.ExternalImage, error) {
|
||||||
|
a, err := s.searchArtist(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
log.Warn(ctx, "Artist not found in Spotify", "artist", name)
|
||||||
|
} else {
|
||||||
|
log.Error(ctx, "Error calling Spotify", "artist", name, err)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []agents.ExternalImage
|
||||||
|
for _, img := range a.Images {
|
||||||
|
res = append(res, agents.ExternalImage{
|
||||||
|
URL: img.URL,
|
||||||
|
Size: img.Width,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spotifyAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||||
|
artists, err := s.client.searchArtists(ctx, name, 40)
|
||||||
|
if err != nil || len(artists) == 0 {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
|
||||||
|
// Sort results, prioritizing artists with images, with similar names and with high popularity, in this order
|
||||||
|
sort.Slice(artists, func(i, j int) bool {
|
||||||
|
ai := fmt.Sprintf("%-5t-%03d-%04d", len(artists[i].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[i].Name), 1, 1, 2), 1000-artists[i].Popularity)
|
||||||
|
aj := fmt.Sprintf("%-5t-%03d-%04d", len(artists[j].Images) == 0, smetrics.WagnerFischer(name, strings.ToLower(artists[j].Name), 1, 1, 2), 1000-artists[j].Popularity)
|
||||||
|
return ai < aj
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the first one has the same name, that's the one
|
||||||
|
if strings.ToLower(artists[0].Name) != name {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
return &artists[0], err
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
conf.AddHook(func() {
|
||||||
|
agents.Register(spotifyAgentName, spotifyConstructor)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package api_test
|
package spotify
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAPI(t *testing.T) {
|
func TestSpotify(t *testing.T) {
|
||||||
tests.Init(t, false)
|
tests.Init(t, false)
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "API Spec Suite")
|
RunSpecs(t, "Spotify Test Suite")
|
||||||
}
|
}
|
||||||
274
adapters/taglib/end_to_end_test.go
Normal file
274
adapters/taglib/end_to_end_test.go
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/djherbis/times"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
"github.com/navidrome/navidrome/utils/gg"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testFileInfo struct {
|
||||||
|
fs.FileInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t testFileInfo) BirthTime() time.Time {
|
||||||
|
if ts := times.Get(t.FileInfo); ts.HasBirthTime() {
|
||||||
|
return ts.BirthTime()
|
||||||
|
}
|
||||||
|
return t.FileInfo.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = Describe("Extractor", func() {
|
||||||
|
toP := func(name, sortName, mbid string) model.Participant {
|
||||||
|
return model.Participant{
|
||||||
|
Artist: model.Artist{Name: name, SortArtistName: sortName, MbzArtistID: mbid},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
roles := []struct {
|
||||||
|
model.Role
|
||||||
|
model.ParticipantList
|
||||||
|
}{
|
||||||
|
{model.RoleComposer, model.ParticipantList{
|
||||||
|
toP("coma a", "a, coma", "bf13b584-f27c-43db-8f42-32898d33d4e2"),
|
||||||
|
toP("comb", "comb", "924039a2-09c6-4d29-9b4f-50cc54447d36"),
|
||||||
|
}},
|
||||||
|
{model.RoleLyricist, model.ParticipantList{
|
||||||
|
toP("la a", "a, la", "c84f648f-68a6-40a2-a0cb-d135b25da3c2"),
|
||||||
|
toP("lb", "lb", "0a7c582d-143a-4540-b4e9-77200835af65"),
|
||||||
|
}},
|
||||||
|
{model.RoleArranger, model.ParticipantList{
|
||||||
|
toP("aa", "", "4605a1d4-8d15-42a3-bd00-9c20e42f71e6"),
|
||||||
|
toP("ab", "", "002f0ff8-77bf-42cc-8216-61a9c43dc145"),
|
||||||
|
}},
|
||||||
|
{model.RoleConductor, model.ParticipantList{
|
||||||
|
toP("cona", "", "af86879b-2141-42af-bad2-389a4dc91489"),
|
||||||
|
toP("conb", "", "3dfa3c70-d7d3-4b97-b953-c298dd305e12"),
|
||||||
|
}},
|
||||||
|
{model.RoleDirector, model.ParticipantList{
|
||||||
|
toP("dia", "", "f943187f-73de-4794-be47-88c66f0fd0f4"),
|
||||||
|
toP("dib", "", "bceb75da-1853-4b3d-b399-b27f0cafc389"),
|
||||||
|
}},
|
||||||
|
{model.RoleEngineer, model.ParticipantList{
|
||||||
|
toP("ea", "", "f634bf6d-d66a-425d-888a-28ad39392759"),
|
||||||
|
toP("eb", "", "243d64ae-d514-44e1-901a-b918d692baee"),
|
||||||
|
}},
|
||||||
|
{model.RoleProducer, model.ParticipantList{
|
||||||
|
toP("pra", "", "d971c8d7-999c-4a5f-ac31-719721ab35d6"),
|
||||||
|
toP("prb", "", "f0a09070-9324-434f-a599-6d25ded87b69"),
|
||||||
|
}},
|
||||||
|
{model.RoleRemixer, model.ParticipantList{
|
||||||
|
toP("ra", "", "c7dc6095-9534-4c72-87cc-aea0103462cf"),
|
||||||
|
toP("rb", "", "8ebeef51-c08c-4736-992f-c37870becedd"),
|
||||||
|
}},
|
||||||
|
{model.RoleDJMixer, model.ParticipantList{
|
||||||
|
toP("dja", "", "d063f13b-7589-4efc-ab7f-c60e6db17247"),
|
||||||
|
toP("djb", "", "3636670c-385f-4212-89c8-0ff51d6bc456"),
|
||||||
|
}},
|
||||||
|
{model.RoleMixer, model.ParticipantList{
|
||||||
|
toP("ma", "", "53fb5a2d-7016-427e-a563-d91819a5f35a"),
|
||||||
|
toP("mb", "", "64c13e65-f0da-4ab9-a300-71ee53b0376a"),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
var e *extractor
|
||||||
|
|
||||||
|
parseTestFile := func(path string) *model.MediaFile {
|
||||||
|
mds, err := e.Parse(path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
info, ok := mds[path]
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
|
||||||
|
fileInfo, err := os.Stat(path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
|
metadata := metadata.New(path, info)
|
||||||
|
mf := metadata.ToMediaFile(1, "folderID")
|
||||||
|
return &mf
|
||||||
|
}
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
e = &extractor{}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("ReplayGain", func() {
|
||||||
|
DescribeTable("test replaygain end-to-end", func(file string, trackGain, trackPeak, albumGain, albumPeak *float64) {
|
||||||
|
mf := parseTestFile("tests/fixtures/" + file)
|
||||||
|
|
||||||
|
Expect(mf.RGTrackGain).To(Equal(trackGain))
|
||||||
|
Expect(mf.RGTrackPeak).To(Equal(trackPeak))
|
||||||
|
Expect(mf.RGAlbumGain).To(Equal(albumGain))
|
||||||
|
Expect(mf.RGAlbumPeak).To(Equal(albumPeak))
|
||||||
|
},
|
||||||
|
Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil),
|
||||||
|
Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("lyrics", func() {
|
||||||
|
makeLyrics := func(code, secondLine string) model.Lyrics {
|
||||||
|
return model.Lyrics{
|
||||||
|
DisplayArtist: "",
|
||||||
|
DisplayTitle: "",
|
||||||
|
Lang: code,
|
||||||
|
Line: []model.Line{
|
||||||
|
{Start: gg.P(int64(0)), Value: "This is"},
|
||||||
|
{Start: gg.P(int64(2500)), Value: secondLine},
|
||||||
|
},
|
||||||
|
Offset: nil,
|
||||||
|
Synced: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
It("should fetch both synced and unsynced lyrics in mixed flac", func() {
|
||||||
|
mf := parseTestFile("tests/fixtures/mixed-lyrics.flac")
|
||||||
|
|
||||||
|
lyrics, err := mf.StructuredLyrics()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lyrics).To(HaveLen(2))
|
||||||
|
|
||||||
|
Expect(lyrics[0].Synced).To(BeTrue())
|
||||||
|
Expect(lyrics[1].Synced).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should handle mp3 with uslt and sylt", func() {
|
||||||
|
mf := parseTestFile("tests/fixtures/test.mp3")
|
||||||
|
|
||||||
|
lyrics, err := mf.StructuredLyrics()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(lyrics).To(HaveLen(4))
|
||||||
|
|
||||||
|
engSylt := makeLyrics("eng", "English SYLT")
|
||||||
|
engUslt := makeLyrics("eng", "English")
|
||||||
|
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||||
|
unsUslt := makeLyrics("xxx", "unspecified")
|
||||||
|
|
||||||
|
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||||
|
mf := parseTestFile("tests/fixtures/" + file)
|
||||||
|
|
||||||
|
lyrics, err := mf.StructuredLyrics()
|
||||||
|
Expect(err).To(Not(HaveOccurred()))
|
||||||
|
Expect(lyrics).To(HaveLen(2))
|
||||||
|
|
||||||
|
unspec := makeLyrics("xxx", "unspecified")
|
||||||
|
eng := makeLyrics("xxx", "English")
|
||||||
|
|
||||||
|
if isId3 {
|
||||||
|
eng.Lang = "eng"
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(lyrics).To(Or(
|
||||||
|
Equal(model.LyricList{unspec, eng}),
|
||||||
|
Equal(model.LyricList{eng, unspec})))
|
||||||
|
},
|
||||||
|
Entry("flac", "test.flac", false),
|
||||||
|
Entry("m4a", "test.m4a", false),
|
||||||
|
Entry("ogg", "test.ogg", false),
|
||||||
|
Entry("wma", "test.wma", false),
|
||||||
|
Entry("wv", "test.wv", false),
|
||||||
|
Entry("wav", "test.wav", true),
|
||||||
|
Entry("aiff", "test.aiff", true),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Participants", func() {
|
||||||
|
DescribeTable("test tags consistent across formats", func(format string) {
|
||||||
|
mf := parseTestFile("tests/fixtures/test." + format)
|
||||||
|
|
||||||
|
for _, data := range roles {
|
||||||
|
role := data.Role
|
||||||
|
artists := data.ParticipantList
|
||||||
|
|
||||||
|
actual := mf.Participants[role]
|
||||||
|
Expect(actual).To(HaveLen(len(artists)))
|
||||||
|
|
||||||
|
for i := range artists {
|
||||||
|
actualArtist := actual[i]
|
||||||
|
expectedArtist := artists[i]
|
||||||
|
|
||||||
|
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||||
|
Expect(actualArtist.SortArtistName).To(Equal(expectedArtist.SortArtistName))
|
||||||
|
Expect(actualArtist.MbzArtistID).To(Equal(expectedArtist.MbzArtistID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if format != "m4a" {
|
||||||
|
performers := mf.Participants[model.RolePerformer]
|
||||||
|
Expect(performers).To(HaveLen(8))
|
||||||
|
|
||||||
|
rules := map[string][]string{
|
||||||
|
"pgaa": {"2fd0b311-9fa8-4ff9-be5d-f6f3d16b835e", "Guitar"},
|
||||||
|
"pgbb": {"223d030b-bf97-4c2a-ad26-b7f7bbe25c93", "Guitar", ""},
|
||||||
|
"pvaa": {"cb195f72-448f-41c8-b962-3f3c13d09d38", "Vocals"},
|
||||||
|
"pvbb": {"60a1f832-8ca2-49f6-8660-84d57f07b520", "Vocals", "Flute"},
|
||||||
|
"pfaa": {"51fb40c-0305-4bf9-a11b-2ee615277725", "", "Flute"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, rule := range rules {
|
||||||
|
mbid := rule[0]
|
||||||
|
for i := 1; i < len(rule); i++ {
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, mapped := range performers {
|
||||||
|
if mapped.Name == name && mapped.MbzArtistID == mbid && mapped.SubRole == rule[i] {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(found).To(BeTrue(), "Could not find matching artist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Entry("FLAC format", "flac"),
|
||||||
|
Entry("M4a format", "m4a"),
|
||||||
|
Entry("OGG format", "ogg"),
|
||||||
|
Entry("WV format", "wv"),
|
||||||
|
|
||||||
|
Entry("MP3 format", "mp3"),
|
||||||
|
Entry("WAV format", "wav"),
|
||||||
|
Entry("AIFF format", "aiff"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("should parse wma", func() {
|
||||||
|
mf := parseTestFile("tests/fixtures/test.wma")
|
||||||
|
|
||||||
|
for _, data := range roles {
|
||||||
|
role := data.Role
|
||||||
|
artists := data.ParticipantList
|
||||||
|
actual := mf.Participants[role]
|
||||||
|
|
||||||
|
// WMA has no Arranger role
|
||||||
|
if role == model.RoleArranger {
|
||||||
|
Expect(actual).To(HaveLen(0))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(actual).To(HaveLen(len(artists)), role.String())
|
||||||
|
|
||||||
|
// For some bizarre reason, the order is inverted. We also don't get
|
||||||
|
// sort names or MBIDs
|
||||||
|
for i := range artists {
|
||||||
|
idx := len(artists) - 1 - i
|
||||||
|
|
||||||
|
actualArtist := actual[i]
|
||||||
|
expectedArtist := artists[idx]
|
||||||
|
|
||||||
|
Expect(actualArtist.Name).To(Equal(expectedArtist.Name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
9
adapters/taglib/get_filename.go
Normal file
9
adapters/taglib/get_filename.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
func getFilename(s string) *C.char {
|
||||||
|
return C.CString(s)
|
||||||
|
}
|
||||||
96
adapters/taglib/get_filename_win.go
Normal file
96
adapters/taglib/get_filename_win.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
// From https://github.com/orofarne/gowchar
|
||||||
|
|
||||||
|
/*
|
||||||
|
#include <wchar.h>
|
||||||
|
|
||||||
|
const size_t SIZEOF_WCHAR_T = sizeof(wchar_t);
|
||||||
|
|
||||||
|
void gowchar_set (wchar_t *arr, int pos, wchar_t val)
|
||||||
|
{
|
||||||
|
arr[pos] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
wchar_t gowchar_get (wchar_t *arr, int pos)
|
||||||
|
{
|
||||||
|
return arr[pos];
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"unicode/utf16"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var SIZEOF_WCHAR_T C.size_t = C.size_t(C.SIZEOF_WCHAR_T)
|
||||||
|
|
||||||
|
func getFilename(s string) *C.wchar_t {
|
||||||
|
wstr, _ := StringToWcharT(s)
|
||||||
|
return wstr
|
||||||
|
}
|
||||||
|
|
||||||
|
func StringToWcharT(s string) (*C.wchar_t, C.size_t) {
|
||||||
|
switch SIZEOF_WCHAR_T {
|
||||||
|
case 2:
|
||||||
|
return stringToWchar2(s) // Windows
|
||||||
|
case 4:
|
||||||
|
return stringToWchar4(s) // Unix
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("Invalid sizeof(wchar_t) = %v", SIZEOF_WCHAR_T))
|
||||||
|
}
|
||||||
|
panic("?!!")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows
|
||||||
|
func stringToWchar2(s string) (*C.wchar_t, C.size_t) {
|
||||||
|
var slen int
|
||||||
|
s1 := s
|
||||||
|
for len(s1) > 0 {
|
||||||
|
r, size := utf8.DecodeRuneInString(s1)
|
||||||
|
if er, _ := utf16.EncodeRune(r); er == '\uFFFD' {
|
||||||
|
slen += 1
|
||||||
|
} else {
|
||||||
|
slen += 2
|
||||||
|
}
|
||||||
|
s1 = s1[size:]
|
||||||
|
}
|
||||||
|
slen++ // \0
|
||||||
|
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
||||||
|
var i int
|
||||||
|
for len(s) > 0 {
|
||||||
|
r, size := utf8.DecodeRuneInString(s)
|
||||||
|
if r1, r2 := utf16.EncodeRune(r); r1 != '\uFFFD' {
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r1))
|
||||||
|
i++
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r2))
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
s = s[size:]
|
||||||
|
}
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
||||||
|
return (*C.wchar_t)(res), C.size_t(slen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix
|
||||||
|
func stringToWchar4(s string) (*C.wchar_t, C.size_t) {
|
||||||
|
slen := utf8.RuneCountInString(s)
|
||||||
|
slen++ // \0
|
||||||
|
res := C.malloc(C.size_t(slen) * SIZEOF_WCHAR_T)
|
||||||
|
var i int
|
||||||
|
for len(s) > 0 {
|
||||||
|
r, size := utf8.DecodeRuneInString(s)
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(i), C.wchar_t(r))
|
||||||
|
s = s[size:]
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
C.gowchar_set((*C.wchar_t)(res), C.int(slen-1), C.wchar_t(0)) // \0
|
||||||
|
return (*C.wchar_t)(res), C.size_t(slen)
|
||||||
|
}
|
||||||
178
adapters/taglib/taglib.go
Normal file
178
adapters/taglib/taglib.go
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/core/storage/local"
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type extractor struct {
|
||||||
|
baseDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
||||||
|
results := make(map[string]metadata.Info)
|
||||||
|
for _, path := range files {
|
||||||
|
props, err := e.extractMetadata(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[path] = *props
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) Version() string {
|
||||||
|
return Version()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
|
||||||
|
fullPath := filepath.Join(e.baseDir, filePath)
|
||||||
|
tags, err := Read(fullPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("extractor: Error reading metadata from file. Skipping", "filePath", fullPath, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse audio properties
|
||||||
|
ap := metadata.AudioProperties{}
|
||||||
|
ap.BitRate = parseProp(tags, "__bitrate")
|
||||||
|
ap.Channels = parseProp(tags, "__channels")
|
||||||
|
ap.SampleRate = parseProp(tags, "__samplerate")
|
||||||
|
ap.BitDepth = parseProp(tags, "__bitspersample")
|
||||||
|
length := parseProp(tags, "__lengthinmilliseconds")
|
||||||
|
ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
|
||||||
|
|
||||||
|
// Extract basic tags
|
||||||
|
parseBasicTag(tags, "__title", "title")
|
||||||
|
parseBasicTag(tags, "__artist", "artist")
|
||||||
|
parseBasicTag(tags, "__album", "album")
|
||||||
|
parseBasicTag(tags, "__comment", "comment")
|
||||||
|
parseBasicTag(tags, "__genre", "genre")
|
||||||
|
parseBasicTag(tags, "__year", "year")
|
||||||
|
parseBasicTag(tags, "__track", "tracknumber")
|
||||||
|
|
||||||
|
// Parse track/disc totals
|
||||||
|
parseTuple := func(prop string) {
|
||||||
|
tagName := prop + "number"
|
||||||
|
tagTotal := prop + "total"
|
||||||
|
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
||||||
|
parts := strings.Split(value[0], "/")
|
||||||
|
tags[tagName] = []string{parts[0]}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
tags[tagTotal] = []string{parts[1]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parseTuple("track")
|
||||||
|
parseTuple("disc")
|
||||||
|
|
||||||
|
// Adjust some ID3 tags
|
||||||
|
parseLyrics(tags)
|
||||||
|
parseTIPL(tags)
|
||||||
|
delete(tags, "tmcl") // TMCL is already parsed by TagLib
|
||||||
|
|
||||||
|
return &metadata.Info{
|
||||||
|
Tags: tags,
|
||||||
|
AudioProperties: ap,
|
||||||
|
HasPicture: tags["has_picture"] != nil && len(tags["has_picture"]) > 0 && tags["has_picture"][0] == "true",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLyrics make sure lyrics tags have language
|
||||||
|
func parseLyrics(tags map[string][]string) {
|
||||||
|
lyrics := tags["lyrics"]
|
||||||
|
if len(lyrics) > 0 {
|
||||||
|
tags["lyrics:xxx"] = lyrics
|
||||||
|
delete(tags, "lyrics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are the only roles we support, based on Picard's tag map:
|
||||||
|
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
||||||
|
var tiplMapping = map[string]string{
|
||||||
|
"arranger": "arranger",
|
||||||
|
"engineer": "engineer",
|
||||||
|
"producer": "producer",
|
||||||
|
"mix": "mixer",
|
||||||
|
"DJ-mix": "djmixer",
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProp parses a property from the tags map and sets it to the target integer.
|
||||||
|
// It also deletes the property from the tags map after parsing.
|
||||||
|
func parseProp(tags map[string][]string, prop string) int {
|
||||||
|
if value, ok := tags[prop]; ok && len(value) > 0 {
|
||||||
|
v, _ := strconv.Atoi(value[0])
|
||||||
|
delete(tags, prop)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
|
||||||
|
// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
|
||||||
|
// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
|
||||||
|
func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
|
||||||
|
basicValue := tags[basicName]
|
||||||
|
if len(basicValue) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(tags, basicName)
|
||||||
|
if len(tags[tagName]) == 0 {
|
||||||
|
tags[tagName] = basicValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
||||||
|
//
|
||||||
|
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
||||||
|
//
|
||||||
|
// and breaks it down into a map of roles and names, e.g.:
|
||||||
|
//
|
||||||
|
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
||||||
|
func parseTIPL(tags map[string][]string) {
|
||||||
|
tipl := tags["tipl"]
|
||||||
|
if len(tipl) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addRole := func(currentRole string, currentValue []string) {
|
||||||
|
if currentRole != "" && len(currentValue) > 0 {
|
||||||
|
role := tiplMapping[currentRole]
|
||||||
|
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentRole string
|
||||||
|
var currentValue []string
|
||||||
|
for _, part := range strings.Split(tipl[0], " ") {
|
||||||
|
if _, ok := tiplMapping[part]; ok {
|
||||||
|
addRole(currentRole, currentValue)
|
||||||
|
currentRole = part
|
||||||
|
currentValue = nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
currentValue = append(currentValue, part)
|
||||||
|
}
|
||||||
|
addRole(currentRole, currentValue)
|
||||||
|
delete(tags, "tipl")
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ local.Extractor = (*extractor)(nil)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
local.RegisterExtractor("legacy-taglib", func(_ fs.FS, baseDir string) local.Extractor {
|
||||||
|
// ignores fs, as taglib extractor only works with local files
|
||||||
|
return &extractor{baseDir}
|
||||||
|
})
|
||||||
|
conf.AddHook(func() {
|
||||||
|
log.Debug("TagLib version", "version", Version())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package id_test
|
package taglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestID(t *testing.T) {
|
func TestTagLib(t *testing.T) {
|
||||||
tests.Init(t, false)
|
tests.Init(t, true)
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "ID Suite")
|
RunSpecs(t, "TagLib Suite")
|
||||||
}
|
}
|
||||||
295
adapters/taglib/taglib_test.go
Normal file
295
adapters/taglib/taglib_test.go
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Extractor", func() {
|
||||||
|
var e *extractor
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
e = &extractor{}
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Parse", func() {
|
||||||
|
It("correctly parses metadata from all files in folder", func() {
|
||||||
|
mds, err := e.Parse(
|
||||||
|
"tests/fixtures/test.mp3",
|
||||||
|
"tests/fixtures/test.ogg",
|
||||||
|
)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(mds).To(HaveLen(2))
|
||||||
|
|
||||||
|
// Test MP3
|
||||||
|
m := mds["tests/fixtures/test.mp3"]
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Song"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||||
|
|
||||||
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
|
Expect(m.AudioProperties.Duration.String()).To(Equal("1.02s"))
|
||||||
|
Expect(m.AudioProperties.BitRate).To(Equal(192))
|
||||||
|
Expect(m.AudioProperties.Channels).To(Equal(2))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(Equal(44100))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("compilation", []string{"1"}),
|
||||||
|
HaveKeyWithValue("tcmp", []string{"1"})),
|
||||||
|
)
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014-05-21"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("originaldate", []string{"1996-11-21"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("releasedate", []string{"2020-12-31"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("discnumber", []string{"1"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_gain", []string{"+3.21518 dB"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_album_peak", []string{"0.9125"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_gain", []string{"-1.48 dB"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("replaygain_track_peak", []string{"0.4512"}))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("tracknumber", []string{"2"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||||
|
|
||||||
|
Expect(m.Tags).ToNot(HaveKey("lyrics"))
|
||||||
|
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:eng", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
}), HaveKeyWithValue("lyrics:eng", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
"[00:00.00]This is\n[00:02.50]English SYLT\n",
|
||||||
|
})))
|
||||||
|
Expect(m.Tags).To(Or(HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
}), HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified SYLT\n",
|
||||||
|
})))
|
||||||
|
|
||||||
|
// Test OGG
|
||||||
|
m = mds["tests/fixtures/test.ogg"]
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||||
|
|
||||||
|
// TagLib 1.12 returns 18, previous versions return 39.
|
||||||
|
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||||
|
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||||
|
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("Format-Specific tests",
|
||||||
|
func(file, duration string, channels, samplerate, bitdepth int, albumGain, albumPeak, trackGain, trackPeak string, id3Lyrics bool, image bool) {
|
||||||
|
file = "tests/fixtures/" + file
|
||||||
|
mds, err := e.Parse(file)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(mds).To(HaveLen(1))
|
||||||
|
|
||||||
|
m := mds[file]
|
||||||
|
|
||||||
|
Expect(m.HasPicture).To(Equal(image))
|
||||||
|
Expect(m.AudioProperties.Duration.String()).To(Equal(duration))
|
||||||
|
Expect(m.AudioProperties.Channels).To(Equal(channels))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(Equal(samplerate))
|
||||||
|
Expect(m.AudioProperties.BitDepth).To(Equal(bitdepth))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
||||||
|
))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_album_peak", []string{albumPeak}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_peak", []string{albumPeak}),
|
||||||
|
))
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_track_gain", []string{trackGain}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{trackGain}),
|
||||||
|
))
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("replaygain_track_peak", []string{trackPeak}),
|
||||||
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_peak", []string{trackPeak}),
|
||||||
|
))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("title", []string{"Title"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("album", []string{"Album"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("artist", []string{"Artist"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("albumartist", []string{"Album Artist"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("genre", []string{"Rock"}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("date", []string{"2014"}))
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("bpm", []string{"123"}))
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("tracknumber", []string{"3"}),
|
||||||
|
HaveKeyWithValue("tracknumber", []string{"3/10"}),
|
||||||
|
))
|
||||||
|
if !strings.HasSuffix(file, "test.wma") {
|
||||||
|
// TODO Not sure why this is not working for WMA
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("tracktotal", []string{"10"}))
|
||||||
|
}
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("discnumber", []string{"1"}),
|
||||||
|
HaveKeyWithValue("discnumber", []string{"1/2"}),
|
||||||
|
))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("disctotal", []string{"2"}))
|
||||||
|
|
||||||
|
// WMA does not have a "compilation" tag, but "wm/iscompilation"
|
||||||
|
Expect(m.Tags).To(Or(
|
||||||
|
HaveKeyWithValue("compilation", []string{"1"}),
|
||||||
|
HaveKeyWithValue("wm/iscompilation", []string{"1"})),
|
||||||
|
)
|
||||||
|
|
||||||
|
if id3Lyrics {
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("lyrics:eng", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
}))
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("lyrics:xxx", []string{
|
||||||
|
"[00:00.00]This is\n[00:02.50]unspecified",
|
||||||
|
"[00:00.00]This is\n[00:02.50]English",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(m.Tags).To(HaveKeyWithValue("comment", []string{"Comment1\nComment2"}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=1200:duration=1" test.flac
|
||||||
|
Entry("correctly parses flac tags", "test.flac", "1s", 1, 44100, 16, "+4.06 dB", "0.12496948", "+4.06 dB", "0.12496948", false, true),
|
||||||
|
|
||||||
|
Entry("correctly parses m4a (aac) gain tags", "01 Invisible (RED) Edit Version.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||||
|
Entry("correctly parses m4a (aac) gain tags (uppercase)", "test.m4a", "1.04s", 2, 44100, 16, "0.37", "0.48", "0.37", "0.48", false, true),
|
||||||
|
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
||||||
|
// Weird note: for the tag parsing to work, the lyrics are actually stored in the reverse order
|
||||||
|
Entry("correctly parses wma/asf tags", "test.wma", "1.02s", 1, 44100, 16, "3.27 dB", "0.132914", "3.27 dB", "0.132914", false, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=800:duration=1" test.wv
|
||||||
|
Entry("correctly parses wv (wavpak) tags", "test.wv", "1s", 1, 44100, 16, "3.43 dB", "0.125061", "3.43 dB", "0.125061", false, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=1000:duration=1" test.wav
|
||||||
|
Entry("correctly parses wav tags", "test.wav", "1s", 1, 44100, 16, "3.06 dB", "0.125056", "3.06 dB", "0.125056", true, true),
|
||||||
|
|
||||||
|
// ffmpeg -f lavfi -i "sine=frequency=1400:duration=1" test.aiff
|
||||||
|
Entry("correctly parses aiff tags", "test.aiff", "1s", 1, 44100, 16, "2.00 dB", "0.124972", "2.00 dB", "0.124972", true, true),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip these tests when running as root
|
||||||
|
Context("Access Forbidden", func() {
|
||||||
|
var accessForbiddenFile string
|
||||||
|
var RegularUserContext = XContext
|
||||||
|
var isRegularUser = os.Getuid() != 0
|
||||||
|
if isRegularUser {
|
||||||
|
RegularUserContext = Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run permission tests if we are not root
|
||||||
|
RegularUserContext("when run without root privileges", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|
||||||
|
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
Expect(f.Close()).To(Succeed())
|
||||||
|
Expect(os.Remove(accessForbiddenFile)).To(Succeed())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("correctly handle unreadable file due to insufficient read permission", func() {
|
||||||
|
_, err := e.extractMetadata(accessForbiddenFile)
|
||||||
|
Expect(err).To(MatchError(os.ErrPermission))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips the file if it cannot be read", func() {
|
||||||
|
files := []string{
|
||||||
|
"tests/fixtures/test.mp3",
|
||||||
|
"tests/fixtures/test.ogg",
|
||||||
|
accessForbiddenFile,
|
||||||
|
}
|
||||||
|
mds, err := e.Parse(files...)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
Expect(mds).To(HaveLen(2))
|
||||||
|
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Error Checking", func() {
|
||||||
|
It("returns a generic ErrPath if file does not exist", func() {
|
||||||
|
testFilePath := "tests/fixtures/NON_EXISTENT.ogg"
|
||||||
|
_, err := e.extractMetadata(testFilePath)
|
||||||
|
Expect(err).To(MatchError(fs.ErrNotExist))
|
||||||
|
})
|
||||||
|
It("does not throw a SIGSEGV error when reading a file with an invalid frame", func() {
|
||||||
|
// File has an empty TDAT frame
|
||||||
|
md, err := e.extractMetadata("tests/fixtures/invalid-files/test-invalid-frame.mp3")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(md.Tags).To(HaveKeyWithValue("albumartist", []string{"Elvis Presley"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("parseTIPL", func() {
|
||||||
|
var tags map[string][]string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
tags = make(map[string][]string)
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when the TIPL string is populated", func() {
|
||||||
|
It("correctly parses roles and names", func() {
|
||||||
|
tags["tipl"] = []string{"arranger Andrew Powell DJ-mix François Kevorkian DJ-mix Jane Doe engineer Chris Blair"}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags["arranger"]).To(ConsistOf("Andrew Powell"))
|
||||||
|
Expect(tags["engineer"]).To(ConsistOf("Chris Blair"))
|
||||||
|
Expect(tags["djmixer"]).To(ConsistOf("François Kevorkian", "Jane Doe"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles multiple names for a single role", func() {
|
||||||
|
tags["tipl"] = []string{"engineer Pat Stapley producer Eric Woolfson engineer Chris Blair"}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags["producer"]).To(ConsistOf("Eric Woolfson"))
|
||||||
|
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("discards roles without names", func() {
|
||||||
|
tags["tipl"] = []string{"engineer Pat Stapley producer engineer Chris Blair"}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags).ToNot(HaveKey("producer"))
|
||||||
|
Expect(tags["engineer"]).To(ConsistOf("Pat Stapley", "Chris Blair"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when the TIPL string is empty", func() {
|
||||||
|
It("does nothing", func() {
|
||||||
|
tags["tipl"] = []string{""}
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("when the TIPL is not present", func() {
|
||||||
|
It("does nothing", func() {
|
||||||
|
parseTIPL(tags)
|
||||||
|
Expect(tags).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
299
adapters/taglib/taglib_wrapper.cpp
Normal file
299
adapters/taglib/taglib_wrapper.cpp
Normal file
|
|
@ -0,0 +1,299 @@
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#define TAGLIB_STATIC
|
||||||
|
#include <apeproperties.h>
|
||||||
|
#include <apetag.h>
|
||||||
|
#include <aifffile.h>
|
||||||
|
#include <asffile.h>
|
||||||
|
#include <dsffile.h>
|
||||||
|
#include <fileref.h>
|
||||||
|
#include <flacfile.h>
|
||||||
|
#include <id3v2tag.h>
|
||||||
|
#include <unsynchronizedlyricsframe.h>
|
||||||
|
#include <synchronizedlyricsframe.h>
|
||||||
|
#include <mp4file.h>
|
||||||
|
#include <mpegfile.h>
|
||||||
|
#include <opusfile.h>
|
||||||
|
#include <tpropertymap.h>
|
||||||
|
#include <vorbisfile.h>
|
||||||
|
#include <wavfile.h>
|
||||||
|
#include <wavfile.h>
|
||||||
|
#include <wavpackfile.h>
|
||||||
|
|
||||||
|
#include "taglib_wrapper.h"
|
||||||
|
|
||||||
|
char has_cover(const TagLib::FileRef f);
|
||||||
|
|
||||||
|
static char TAGLIB_VERSION[16];
|
||||||
|
|
||||||
|
char* taglib_version() {
|
||||||
|
snprintf((char *)TAGLIB_VERSION, 16, "%d.%d.%d", TAGLIB_MAJOR_VERSION, TAGLIB_MINOR_VERSION, TAGLIB_PATCH_VERSION);
|
||||||
|
return (char *)TAGLIB_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
|
||||||
|
TagLib::FileRef f(filename, true, TagLib::AudioProperties::Fast);
|
||||||
|
|
||||||
|
if (f.isNull()) {
|
||||||
|
return TAGLIB_ERR_PARSE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!f.audioProperties()) {
|
||||||
|
return TAGLIB_ERR_AUDIO_PROPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add audio properties to the tags
|
||||||
|
const TagLib::AudioProperties *props(f.audioProperties());
|
||||||
|
goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
|
||||||
|
goPutInt(id, (char *)"__bitrate", props->bitrate());
|
||||||
|
goPutInt(id, (char *)"__channels", props->channels());
|
||||||
|
goPutInt(id, (char *)"__samplerate", props->sampleRate());
|
||||||
|
|
||||||
|
// Extract bits per sample for supported formats
|
||||||
|
int bitsPerSample = 0;
|
||||||
|
if (const auto* apeProperties{ dynamic_cast<const TagLib::APE::Properties*>(props) })
|
||||||
|
bitsPerSample = apeProperties->bitsPerSample();
|
||||||
|
else if (const auto* asfProperties{ dynamic_cast<const TagLib::ASF::Properties*>(props) })
|
||||||
|
bitsPerSample = asfProperties->bitsPerSample();
|
||||||
|
else if (const auto* flacProperties{ dynamic_cast<const TagLib::FLAC::Properties*>(props) })
|
||||||
|
bitsPerSample = flacProperties->bitsPerSample();
|
||||||
|
else if (const auto* mp4Properties{ dynamic_cast<const TagLib::MP4::Properties*>(props) })
|
||||||
|
bitsPerSample = mp4Properties->bitsPerSample();
|
||||||
|
else if (const auto* wavePackProperties{ dynamic_cast<const TagLib::WavPack::Properties*>(props) })
|
||||||
|
bitsPerSample = wavePackProperties->bitsPerSample();
|
||||||
|
else if (const auto* aiffProperties{ dynamic_cast<const TagLib::RIFF::AIFF::Properties*>(props) })
|
||||||
|
bitsPerSample = aiffProperties->bitsPerSample();
|
||||||
|
else if (const auto* wavProperties{ dynamic_cast<const TagLib::RIFF::WAV::Properties*>(props) })
|
||||||
|
bitsPerSample = wavProperties->bitsPerSample();
|
||||||
|
else if (const auto* dsfProperties{ dynamic_cast<const TagLib::DSF::Properties*>(props) })
|
||||||
|
bitsPerSample = dsfProperties->bitsPerSample();
|
||||||
|
|
||||||
|
if (bitsPerSample > 0) {
|
||||||
|
goPutInt(id, (char *)"__bitspersample", bitsPerSample);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all properties to the Go map
|
||||||
|
TagLib::PropertyMap tags = f.file()->properties();
|
||||||
|
|
||||||
|
// Make sure at least the basic properties are extracted
|
||||||
|
TagLib::Tag *basic = f.file()->tag();
|
||||||
|
if (!basic->isEmpty()) {
|
||||||
|
if (!basic->title().isEmpty()) {
|
||||||
|
tags.insert("__title", basic->title());
|
||||||
|
}
|
||||||
|
if (!basic->artist().isEmpty()) {
|
||||||
|
tags.insert("__artist", basic->artist());
|
||||||
|
}
|
||||||
|
if (!basic->album().isEmpty()) {
|
||||||
|
tags.insert("__album", basic->album());
|
||||||
|
}
|
||||||
|
if (!basic->comment().isEmpty()) {
|
||||||
|
tags.insert("__comment", basic->comment());
|
||||||
|
}
|
||||||
|
if (!basic->genre().isEmpty()) {
|
||||||
|
tags.insert("__genre", basic->genre());
|
||||||
|
}
|
||||||
|
if (basic->year() > 0) {
|
||||||
|
tags.insert("__year", TagLib::String::number(basic->year()));
|
||||||
|
}
|
||||||
|
if (basic->track() > 0) {
|
||||||
|
tags.insert("__track", TagLib::String::number(basic->track()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TagLib::ID3v2::Tag *id3Tags = NULL;
|
||||||
|
|
||||||
|
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
|
||||||
|
TagLib::MPEG::File *mp3File(dynamic_cast<TagLib::MPEG::File *>(f.file()));
|
||||||
|
if (mp3File != NULL) {
|
||||||
|
id3Tags = mp3File->ID3v2Tag();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id3Tags == NULL) {
|
||||||
|
TagLib::RIFF::WAV::File *wavFile(dynamic_cast<TagLib::RIFF::WAV::File *>(f.file()));
|
||||||
|
if (wavFile != NULL && wavFile->hasID3v2Tag()) {
|
||||||
|
id3Tags = wavFile->ID3v2Tag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id3Tags == NULL) {
|
||||||
|
TagLib::RIFF::AIFF::File *aiffFile(dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file()));
|
||||||
|
if (aiffFile && aiffFile->hasID3v2Tag()) {
|
||||||
|
id3Tags = aiffFile->tag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Yes, it is possible to have ID3v2 tags in FLAC. However, that can cause problems
|
||||||
|
// with many players, so they will not be parsed
|
||||||
|
|
||||||
|
if (id3Tags != NULL) {
|
||||||
|
const auto &frames = id3Tags->frameListMap();
|
||||||
|
|
||||||
|
for (const auto &kv: frames) {
|
||||||
|
if (kv.first == "USLT") {
|
||||||
|
for (const auto &tag: kv.second) {
|
||||||
|
TagLib::ID3v2::UnsynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::UnsynchronizedLyricsFrame *>(tag);
|
||||||
|
if (frame == NULL) continue;
|
||||||
|
|
||||||
|
tags.erase("LYRICS");
|
||||||
|
|
||||||
|
const auto bv = frame->language();
|
||||||
|
char language[4] = {'x', 'x', 'x', '\0'};
|
||||||
|
if (bv.size() == 3) {
|
||||||
|
strncpy(language, bv.data(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
char *val = const_cast<char*>(frame->text().toCString(true));
|
||||||
|
|
||||||
|
goPutLyrics(id, language, val);
|
||||||
|
}
|
||||||
|
} else if (kv.first == "SYLT") {
|
||||||
|
for (const auto &tag: kv.second) {
|
||||||
|
TagLib::ID3v2::SynchronizedLyricsFrame *frame = dynamic_cast<TagLib::ID3v2::SynchronizedLyricsFrame *>(tag);
|
||||||
|
if (frame == NULL) continue;
|
||||||
|
|
||||||
|
const auto bv = frame->language();
|
||||||
|
char language[4] = {'x', 'x', 'x', '\0'};
|
||||||
|
if (bv.size() == 3) {
|
||||||
|
strncpy(language, bv.data(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto format = frame->timestampFormat();
|
||||||
|
if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMilliseconds) {
|
||||||
|
|
||||||
|
for (const auto &line: frame->synchedText()) {
|
||||||
|
char *text = const_cast<char*>(line.text.toCString(true));
|
||||||
|
goPutLyricLine(id, language, text, line.time);
|
||||||
|
}
|
||||||
|
} else if (format == TagLib::ID3v2::SynchronizedLyricsFrame::AbsoluteMpegFrames) {
|
||||||
|
const int sampleRate = props->sampleRate();
|
||||||
|
|
||||||
|
if (sampleRate != 0) {
|
||||||
|
for (const auto &line: frame->synchedText()) {
|
||||||
|
const int timeInMs = (line.time * 1000) / sampleRate;
|
||||||
|
char *text = const_cast<char*>(line.text.toCString(true));
|
||||||
|
goPutLyricLine(id, language, text, timeInMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kv.first == "TIPL"){
|
||||||
|
if (!kv.second.isEmpty()) {
|
||||||
|
tags.insert(kv.first, kv.second.front()->toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// M4A may have some iTunes specific tags not captured by the PropertyMap interface
|
||||||
|
TagLib::MP4::File *m4afile(dynamic_cast<TagLib::MP4::File *>(f.file()));
|
||||||
|
if (m4afile != NULL) {
|
||||||
|
const auto itemListMap = m4afile->tag()->itemMap();
|
||||||
|
for (const auto item: itemListMap) {
|
||||||
|
char *key = const_cast<char*>(item.first.toCString(true));
|
||||||
|
for (const auto value: item.second.toStringList()) {
|
||||||
|
char *val = const_cast<char*>(value.toCString(true));
|
||||||
|
goPutM4AStr(id, key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WMA/ASF files may have additional tags not captured by the PropertyMap interface
|
||||||
|
TagLib::ASF::File *asfFile(dynamic_cast<TagLib::ASF::File *>(f.file()));
|
||||||
|
if (asfFile != NULL) {
|
||||||
|
const TagLib::ASF::Tag *asfTags{asfFile->tag()};
|
||||||
|
const auto itemListMap = asfTags->attributeListMap();
|
||||||
|
for (const auto item : itemListMap) {
|
||||||
|
char *key = const_cast<char*>(item.first.toCString(true));
|
||||||
|
|
||||||
|
for (auto j = item.second.begin();
|
||||||
|
j != item.second.end(); ++j) {
|
||||||
|
|
||||||
|
char *val = const_cast<char*>(j->toString().toCString(true));
|
||||||
|
goPutStr(id, key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all collected tags to the Go map
|
||||||
|
for (TagLib::PropertyMap::ConstIterator i = tags.begin(); i != tags.end();
|
||||||
|
++i) {
|
||||||
|
char *key = const_cast<char*>(i->first.toCString(true));
|
||||||
|
for (TagLib::StringList::ConstIterator j = i->second.begin();
|
||||||
|
j != i->second.end(); ++j) {
|
||||||
|
char *val = const_cast<char*>((*j).toCString(true));
|
||||||
|
goPutStr(id, key, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cover art has to be handled separately
|
||||||
|
if (has_cover(f)) {
|
||||||
|
goPutStr(id, (char *)"has_picture", (char *)"true");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect if the file has cover art. Returns 1 if the file has cover art, 0 otherwise.
|
||||||
|
char has_cover(const TagLib::FileRef f) {
|
||||||
|
char hasCover = 0;
|
||||||
|
// ----- MP3
|
||||||
|
if (TagLib::MPEG::File * mp3File{dynamic_cast<TagLib::MPEG::File *>(f.file())}) {
|
||||||
|
if (mp3File->ID3v2Tag()) {
|
||||||
|
const auto &frameListMap{mp3File->ID3v2Tag()->frameListMap()};
|
||||||
|
hasCover = !frameListMap["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ----- FLAC
|
||||||
|
else if (TagLib::FLAC::File * flacFile{dynamic_cast<TagLib::FLAC::File *>(f.file())}) {
|
||||||
|
hasCover = !flacFile->pictureList().isEmpty();
|
||||||
|
}
|
||||||
|
// ----- MP4
|
||||||
|
else if (TagLib::MP4::File * mp4File{dynamic_cast<TagLib::MP4::File *>(f.file())}) {
|
||||||
|
auto &coverItem{mp4File->tag()->itemMap()["covr"]};
|
||||||
|
TagLib::MP4::CoverArtList coverArtList{coverItem.toCoverArtList()};
|
||||||
|
hasCover = !coverArtList.isEmpty();
|
||||||
|
}
|
||||||
|
// ----- Ogg
|
||||||
|
else if (TagLib::Ogg::Vorbis::File * vorbisFile{dynamic_cast<TagLib::Ogg::Vorbis::File *>(f.file())}) {
|
||||||
|
hasCover = !vorbisFile->tag()->pictureList().isEmpty();
|
||||||
|
}
|
||||||
|
// ----- Opus
|
||||||
|
else if (TagLib::Ogg::Opus::File * opusFile{dynamic_cast<TagLib::Ogg::Opus::File *>(f.file())}) {
|
||||||
|
hasCover = !opusFile->tag()->pictureList().isEmpty();
|
||||||
|
}
|
||||||
|
// ----- WAV
|
||||||
|
else if (TagLib::RIFF::WAV::File * wavFile{ dynamic_cast<TagLib::RIFF::WAV::File*>(f.file()) }) {
|
||||||
|
if (wavFile->hasID3v2Tag()) {
|
||||||
|
const auto& frameListMap{ wavFile->ID3v2Tag()->frameListMap() };
|
||||||
|
hasCover = !frameListMap["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ----- AIFF
|
||||||
|
else if (TagLib::RIFF::AIFF::File * aiffFile{ dynamic_cast<TagLib::RIFF::AIFF::File *>(f.file())}) {
|
||||||
|
if (aiffFile->hasID3v2Tag()) {
|
||||||
|
const auto& frameListMap{ aiffFile->tag()->frameListMap() };
|
||||||
|
hasCover = !frameListMap["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ----- WMA
|
||||||
|
else if (TagLib::ASF::File * asfFile{dynamic_cast<TagLib::ASF::File *>(f.file())}) {
|
||||||
|
const TagLib::ASF::Tag *tag{ asfFile->tag() };
|
||||||
|
hasCover = tag && tag->attributeListMap().contains("WM/Picture");
|
||||||
|
}
|
||||||
|
// ----- DSF
|
||||||
|
else if (TagLib::DSF::File * dsffile{ dynamic_cast<TagLib::DSF::File *>(f.file())}) {
|
||||||
|
const TagLib::ID3v2::Tag *tag { dsffile->tag() };
|
||||||
|
hasCover = tag && !tag->frameListMap()["APIC"].isEmpty();
|
||||||
|
}
|
||||||
|
// ----- WAVPAK (APE tag)
|
||||||
|
else if (TagLib::WavPack::File * wvFile{dynamic_cast<TagLib::WavPack::File *>(f.file())}) {
|
||||||
|
if (wvFile->hasAPETag()) {
|
||||||
|
// This is the particular string that Picard uses
|
||||||
|
hasCover = !wvFile->APETag()->itemListMap()["COVER ART (FRONT)"].isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasCover;
|
||||||
|
}
|
||||||
157
adapters/taglib/taglib_wrapper.go
Normal file
157
adapters/taglib/taglib_wrapper.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package taglib
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo !windows pkg-config: --define-prefix taglib
|
||||||
|
#cgo windows pkg-config: taglib
|
||||||
|
#cgo illumos LDFLAGS: -lstdc++ -lsendfile
|
||||||
|
#cgo linux darwin CXXFLAGS: -std=c++11
|
||||||
|
#cgo darwin LDFLAGS: -L/opt/homebrew/opt/taglib/lib
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include "taglib_wrapper.h"
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime/debug"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const iTunesKeyPrefix = "----:com.apple.itunes:"
|
||||||
|
|
||||||
|
func Version() string {
|
||||||
|
return C.GoString(C.taglib_version())
|
||||||
|
}
|
||||||
|
|
||||||
|
func Read(filename string) (tags map[string][]string, err error) {
|
||||||
|
// Do not crash on failures in the C code/library
|
||||||
|
debug.SetPanicOnFault(true)
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Error("extractor: recovered from panic when reading tags", "file", filename, "error", r)
|
||||||
|
err = fmt.Errorf("extractor: recovered from panic: %s", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fp := getFilename(filename)
|
||||||
|
defer C.free(unsafe.Pointer(fp))
|
||||||
|
id, m, release := newMap()
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
log.Trace("extractor: reading tags", "filename", filename, "map_id", id)
|
||||||
|
res := C.taglib_read(fp, C.ulong(id))
|
||||||
|
switch res {
|
||||||
|
case C.TAGLIB_ERR_PARSE:
|
||||||
|
// Check additional case whether the file is unreadable due to permission
|
||||||
|
file, fileErr := os.OpenFile(filename, os.O_RDONLY, 0600)
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if os.IsPermission(fileErr) {
|
||||||
|
return nil, fmt.Errorf("navidrome does not have permission: %w", fileErr)
|
||||||
|
} else if fileErr != nil {
|
||||||
|
return nil, fmt.Errorf("cannot parse file media file: %w", fileErr)
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("cannot parse file media file")
|
||||||
|
}
|
||||||
|
case C.TAGLIB_ERR_AUDIO_PROPS:
|
||||||
|
return nil, fmt.Errorf("can't get audio properties from file")
|
||||||
|
}
|
||||||
|
if log.IsGreaterOrEqualTo(log.LevelDebug) {
|
||||||
|
j, _ := json.Marshal(m)
|
||||||
|
log.Trace("extractor: read tags", "tags", string(j), "filename", filename, "id", id)
|
||||||
|
} else {
|
||||||
|
log.Trace("extractor: read tags", "tags", m, "filename", filename, "id", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagMap map[string][]string
|
||||||
|
|
||||||
|
var allMaps sync.Map
|
||||||
|
var mapsNextID atomic.Uint32
|
||||||
|
|
||||||
|
func newMap() (uint32, tagMap, func()) {
|
||||||
|
id := mapsNextID.Add(1)
|
||||||
|
|
||||||
|
m := tagMap{}
|
||||||
|
allMaps.Store(id, m)
|
||||||
|
|
||||||
|
return id, m, func() {
|
||||||
|
allMaps.Delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func doPutTag(id C.ulong, key string, val *C.char) {
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r, _ := allMaps.Load(uint32(id))
|
||||||
|
m := r.(tagMap)
|
||||||
|
k := strings.ToLower(key)
|
||||||
|
v := strings.TrimSpace(C.GoString(val))
|
||||||
|
m[k] = append(m[k], v)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutM4AStr
|
||||||
|
func goPutM4AStr(id C.ulong, key *C.char, val *C.char) {
|
||||||
|
k := C.GoString(key)
|
||||||
|
|
||||||
|
// Special for M4A, do not catch keys that have no actual name
|
||||||
|
k = strings.TrimPrefix(k, iTunesKeyPrefix)
|
||||||
|
doPutTag(id, k, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutStr
|
||||||
|
func goPutStr(id C.ulong, key *C.char, val *C.char) {
|
||||||
|
doPutTag(id, C.GoString(key), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutInt
|
||||||
|
func goPutInt(id C.ulong, key *C.char, val C.int) {
|
||||||
|
valStr := strconv.Itoa(int(val))
|
||||||
|
vp := C.CString(valStr)
|
||||||
|
defer C.free(unsafe.Pointer(vp))
|
||||||
|
goPutStr(id, key, vp)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutLyrics
|
||||||
|
func goPutLyrics(id C.ulong, lang *C.char, val *C.char) {
|
||||||
|
doPutTag(id, "lyrics:"+C.GoString(lang), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
//export goPutLyricLine
|
||||||
|
func goPutLyricLine(id C.ulong, lang *C.char, text *C.char, time C.int) {
|
||||||
|
language := C.GoString(lang)
|
||||||
|
line := C.GoString(text)
|
||||||
|
timeGo := int64(time)
|
||||||
|
|
||||||
|
ms := timeGo % 1000
|
||||||
|
timeGo /= 1000
|
||||||
|
sec := timeGo % 60
|
||||||
|
timeGo /= 60
|
||||||
|
minimum := timeGo % 60
|
||||||
|
formattedLine := fmt.Sprintf("[%02d:%02d.%02d]%s\n", minimum, sec, ms/10, line)
|
||||||
|
|
||||||
|
key := "lyrics:" + language
|
||||||
|
|
||||||
|
r, _ := allMaps.Load(uint32(id))
|
||||||
|
m := r.(tagMap)
|
||||||
|
k := strings.ToLower(key)
|
||||||
|
existing, ok := m[k]
|
||||||
|
if ok {
|
||||||
|
existing[0] += formattedLine
|
||||||
|
} else {
|
||||||
|
m[k] = []string{formattedLine}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
adapters/taglib/taglib_wrapper.h
Normal file
24
adapters/taglib/taglib_wrapper.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#define TAGLIB_ERR_PARSE -1
|
||||||
|
#define TAGLIB_ERR_AUDIO_PROPS -2
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef WIN32
|
||||||
|
#define FILENAME_CHAR_T wchar_t
|
||||||
|
#else
|
||||||
|
#define FILENAME_CHAR_T char
|
||||||
|
#endif
|
||||||
|
|
||||||
|
extern void goPutM4AStr(unsigned long id, char *key, char *val);
|
||||||
|
extern void goPutStr(unsigned long id, char *key, char *val);
|
||||||
|
extern void goPutInt(unsigned long id, char *key, int val);
|
||||||
|
extern void goPutLyrics(unsigned long id, char *lang, char *val);
|
||||||
|
extern void goPutLyricLine(unsigned long id, char *lang, char *text, int time);
|
||||||
|
int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id);
|
||||||
|
char* taglib_version();
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
response-property-enum-value-added INFO
|
|
||||||
155
api/.vacuum.yaml
155
api/.vacuum.yaml
|
|
@ -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
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
35
api/embed.go
35
api/embed.go
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
description: Entity tag for `If-None-Match` revalidation.
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
name: offset
|
|
||||||
in: query
|
|
||||||
description: Zero-based index of the first item to return.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: integer
|
|
||||||
minimum: 0
|
|
||||||
default: 0
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: The request is malformed or fails validation.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: The caller is authenticated but not allowed to do this.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: Unexpected server failure. Details are in the server log.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: No such resource or endpoint.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
description: Not modified.
|
|
||||||
headers:
|
|
||||||
ETag:
|
|
||||||
$ref: ../headers/ETag.yaml
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
description: Missing, invalid, or expired credentials.
|
|
||||||
content:
|
|
||||||
application/problem+json:
|
|
||||||
schema:
|
|
||||||
$ref: ../schemas/Problem.yaml
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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]
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
|
||||||
1022
cmd/artwork.go
1022
cmd/artwork.go
File diff suppressed because it is too large
Load diff
1206
cmd/artwork_test.go
1206
cmd/artwork_test.go
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,9 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -29,7 +31,7 @@ func init() {
|
||||||
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
||||||
backupRoot.AddCommand(pruneCmd)
|
backupRoot.AddCommand(pruneCmd)
|
||||||
|
|
||||||
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
|
||||||
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||||
backupRoot.AddCommand(restoreCommand)
|
backupRoot.AddCommand(restoreCommand)
|
||||||
|
|
@ -73,15 +75,27 @@ var (
|
||||||
|
|
||||||
func runBackup(ctx context.Context) {
|
func runBackup(ctx context.Context) {
|
||||||
if backupDir != "" {
|
if backupDir != "" {
|
||||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
conf.Server.Backup.Path = backupDir
|
||||||
}
|
}
|
||||||
|
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
path = conf.Server.DbPath
|
||||||
|
} else {
|
||||||
|
path = conf.Server.DbPath[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
path, err := db.Backup(ctx)
|
path, err := db.Backup(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error backing up database", "backupPath", conf.Server.Backup.Path, err)
|
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -90,24 +104,43 @@ func runBackup(ctx context.Context) {
|
||||||
|
|
||||||
func runPrune(ctx context.Context) {
|
func runPrune(ctx context.Context) {
|
||||||
if backupDir != "" {
|
if backupDir != "" {
|
||||||
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
conf.Server.Backup.Path = backupDir
|
||||||
}
|
}
|
||||||
|
|
||||||
if backupCount != -1 {
|
if backupCount != -1 {
|
||||||
conf.Server.Backup.Count = backupCount
|
conf.Server.Backup.Count = backupCount
|
||||||
}
|
}
|
||||||
|
|
||||||
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
if conf.Server.Backup.Count == 0 && !force {
|
||||||
|
fmt.Println("Warning: pruning ALL backups")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
log.Warn("Prune cancelled")
|
log.Warn("Prune cancelled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
path = conf.Server.DbPath
|
||||||
|
} else {
|
||||||
|
path = conf.Server.DbPath[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
count, err := db.Prune(ctx)
|
count, err := db.Prune(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error pruning database", "backupPath", conf.Server.Backup.Path, err)
|
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -116,29 +149,36 @@ func runPrune(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRestore(ctx context.Context) {
|
func runRestore(ctx context.Context) {
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
// A relative --backup-file is resolved against Backup.Path, the same folder
|
if idx == -1 {
|
||||||
// `backup create` writes to. Without this, the value was treated as relative
|
path = conf.Server.DbPath
|
||||||
// to the working directory, where the file does not exist.
|
} else {
|
||||||
if !filepath.IsAbs(restorePath) {
|
path = conf.Server.DbPath[:idx]
|
||||||
backupPath, err := conf.Server.Backup.Path.Path()
|
}
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
restorePath = filepath.Join(backupPath, restorePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
|
if !force {
|
||||||
|
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
log.Warn("Restore cancelled")
|
log.Warn("Restore cancelled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := db.Restore(ctx, restorePath)
|
err := db.Restore(ctx, restorePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(doctorCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
|
||||||
Use: "doctor",
|
|
||||||
Short: "Check your Navidrome installation for problems",
|
|
||||||
Long: "Run read-only health checks and report what was found. Checks the database for " +
|
|
||||||
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
|
|
||||||
"can fix what it finds. This command never alters your data",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runDoctor(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDoctor(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
healthy := doctor(ctx, db.Db(), os.Stdout)
|
|
||||||
db.Close(ctx)
|
|
||||||
if !healthy {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
|
|
||||||
|
|
||||||
func printFindings(out io.Writer, check, noun string, items []string) {
|
|
||||||
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
|
|
||||||
for _, item := range items {
|
|
||||||
fmt.Fprintln(out, " "+item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
|
|
||||||
healthy := true
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking database integrity...")
|
|
||||||
issues, truncated, err := db.IntegrityCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
return false
|
|
||||||
case len(issues) == 0:
|
|
||||||
fmt.Fprintln(out, "Integrity check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
printFindings(out, "Integrity check", "issue(s)", issues)
|
|
||||||
switch {
|
|
||||||
case truncated:
|
|
||||||
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
case db.IsFTSCorruptionOnly(issues):
|
|
||||||
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
|
|
||||||
default:
|
|
||||||
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking foreign keys...")
|
|
||||||
violations, err := db.ForeignKeyCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
healthy = false
|
|
||||||
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
|
|
||||||
case len(violations) == 0:
|
|
||||||
fmt.Fprintln(out, "Foreign key check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
lines := make([]string, 0, len(violations))
|
|
||||||
for _, v := range violations {
|
|
||||||
lines = append(lines,
|
|
||||||
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
|
|
||||||
}
|
|
||||||
printFindings(out, "Foreign key check", "violation(s)", lines)
|
|
||||||
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
|
|
||||||
"in library data; the rest have to be removed by hand.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if healthy {
|
|
||||||
fmt.Fprintln(out, "Database is healthy.")
|
|
||||||
}
|
|
||||||
return healthy
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("doctor", func() {
|
|
||||||
var (
|
|
||||||
ctx context.Context
|
|
||||||
dbPath string
|
|
||||||
database *sql.DB
|
|
||||||
out *strings.Builder
|
|
||||||
reopen func()
|
|
||||||
)
|
|
||||||
|
|
||||||
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
|
|
||||||
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = context.Background()
|
|
||||||
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
|
|
||||||
reopen = func() {
|
|
||||||
var err error
|
|
||||||
database, err = sql.Open(db.Dialect, dbPath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
database.SetMaxOpenConns(1)
|
|
||||||
}
|
|
||||||
reopen()
|
|
||||||
DeferCleanup(func() { _ = database.Close() })
|
|
||||||
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
|
|
||||||
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
|
|
||||||
`create table library(id integer primary key)`,
|
|
||||||
`create table media_file(id integer primary key, library_id integer references library(id))`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
out = &strings.Builder{}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports a healthy database", func() {
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeTrue())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Database is healthy."))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to 'search rebuild' when corruption is limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to a backup restore when corruption is not limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`insert into library(id)
|
|
||||||
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
|
|
||||||
select x from s`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
var rootPage, pageSize int64
|
|
||||||
Expect(database.QueryRowContext(ctx,
|
|
||||||
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
|
|
||||||
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(f.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports foreign key violations", func() {
|
|
||||||
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("media_file"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
|
|
||||||
// GC never touches player, share or playqueue, so don't promise a full cleanup.
|
|
||||||
Expect(out.String()).To(ContainSubstring("removed by hand"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
|
|
||||||
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
|
|
||||||
It("does not blame the search index when the issue list is truncated", func() {
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create table t(a, b)`,
|
|
||||||
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
|
|
||||||
insert into t select x, x + 10000 from s`,
|
|
||||||
`create index media_file_fts_probe on t(a)`,
|
|
||||||
`pragma writable_schema=on`,
|
|
||||||
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
|
|
||||||
where name = 'media_file_fts_probe'`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -32,21 +28,21 @@ var inspectCmd = &cobra.Command{
|
||||||
Long: "Show file tags as seen by Navidrome",
|
Long: "Show file tags as seen by Navidrome",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runInspector(cmd.Context(), args)
|
runInspector(args)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var marshalers = map[string]func(any) ([]byte, error){
|
var marshalers = map[string]func(interface{}) ([]byte, error){
|
||||||
"pretty": prettyMarshal,
|
"pretty": prettyMarshal,
|
||||||
"toml": toml.Marshal,
|
"toml": toml.Marshal,
|
||||||
"yaml": yaml.Marshal,
|
"yaml": yaml.Marshal,
|
||||||
"json": json.Marshal,
|
"json": json.Marshal,
|
||||||
"jsonindent": func(v any) ([]byte, error) {
|
"jsonindent": func(v interface{}) ([]byte, error) {
|
||||||
return json.MarshalIndent(v, "", " ")
|
return json.MarshalIndent(v, "", " ")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func prettyMarshal(v any) ([]byte, error) {
|
func prettyMarshal(v interface{}) ([]byte, error) {
|
||||||
out := v.([]core.InspectOutput)
|
out := v.([]core.InspectOutput)
|
||||||
var res strings.Builder
|
var res strings.Builder
|
||||||
for i := range out {
|
for i := range out {
|
||||||
|
|
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
|
||||||
return []byte(res.String()), nil
|
return []byte(res.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspector(ctx context.Context, args []string) {
|
func runInspector(args []string) {
|
||||||
marshal := marshalers[format]
|
marshal := marshalers[format]
|
||||||
if marshal == nil {
|
if marshal == nil {
|
||||||
log.Fatal("Invalid format", "format", format)
|
log.Fatal("Invalid format", "format", format)
|
||||||
}
|
}
|
||||||
libs := loadLibraries(ctx)
|
|
||||||
matcher := model.NewLibraryMatcher(libs)
|
|
||||||
var out []core.InspectOutput
|
var out []core.InspectOutput
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
if !model.IsAudioFile(filePath) {
|
if !model.IsAudioFile(filePath) {
|
||||||
log.Warn("Not an audio file", "file", filePath)
|
log.Warn("Not an audio file", "file", filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lib, ok := libraryForFile(matcher, filePath)
|
output, err := core.Inspect(filePath, 1, "")
|
||||||
if !ok && len(libs) > 0 {
|
|
||||||
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
|
||||||
}
|
|
||||||
output, err := core.Inspect(filePath, lib, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
|
||||||
data, _ := marshal(out)
|
data, _ := marshal(out)
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
|
|
||||||
func loadLibraries(ctx context.Context) model.Libraries {
|
|
||||||
if dbFile, ok := existingDBFile(); !ok {
|
|
||||||
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer db.Init(ctx)()
|
|
||||||
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range libs {
|
|
||||||
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
|
|
||||||
libs[i].Path = absPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return libs
|
|
||||||
}
|
|
||||||
|
|
||||||
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
|
|
||||||
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
|
|
||||||
if absPath, err := filepath.Abs(filePath); err == nil {
|
|
||||||
if lib, ok := matcher.FindLibrary(absPath); ok {
|
|
||||||
return lib, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.Library{ID: model.DefaultLibraryID}, false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("inspect", func() {
|
|
||||||
Describe("libraryForFile", func() {
|
|
||||||
var matcher *model.LibraryMatcher
|
|
||||||
var root string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
root = GinkgoT().TempDir()
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
matcher = model.NewLibraryMatcher(model.Libraries{
|
|
||||||
{ID: 1, Path: filepath.Join(root, "music")},
|
|
||||||
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the library that contains an absolute path", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(lib.ID).To(Equal(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("resolves a relative path against the working directory", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(lib.PIDAlbum).To(Equal("folder"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to the default library without overrides", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
|
|
||||||
Expect(ok).To(BeFalse())
|
|
||||||
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("loadLibraries", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not create a database when there is none", func() {
|
|
||||||
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
|
|
||||||
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
|
|
||||||
|
|
||||||
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
|
|
||||||
Expect(dbFile).ToNot(BeAnExistingFile())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
171
cmd/missing.go
171
cmd/missing.go
|
|
@ -1,171 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var missingListFormat string
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
missingListCmd.Flags().StringVarP(&missingListFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
|
||||||
missingCmd.AddCommand(missingListCmd)
|
|
||||||
missingCmd.AddCommand(missingFixCmd)
|
|
||||||
rootCmd.AddCommand(missingCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
missingCmd = &cobra.Command{
|
|
||||||
Use: "missing",
|
|
||||||
Short: "Manage missing files",
|
|
||||||
Long: "List files marked as missing and remap them onto existing files",
|
|
||||||
}
|
|
||||||
|
|
||||||
missingListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List missing files",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runMissingList(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
missingFixCmd = &cobra.Command{
|
|
||||||
Use: "fix <missing path|id> <target path|id>",
|
|
||||||
Short: "Remap a missing file onto an existing file",
|
|
||||||
Long: "Remap a file marked as missing onto an existing (non-missing) file, the same way\n" +
|
|
||||||
"the scanner reconciles moved or renamed files. Each argument may be a media file ID,\n" +
|
|
||||||
"a library-relative path, or a libraryID:path pair.",
|
|
||||||
Args: cobra.ExactArgs(2),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runMissingFix(cmd.Context(), args[0], args[1])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
type displayMissingFile struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
LibraryID int `json:"libraryId"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Album string `json:"album"`
|
|
||||||
Artist string `json:"artist"`
|
|
||||||
Path string `json:"path"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMissingList(ctx context.Context) {
|
|
||||||
if missingListFormat != "csv" && missingListFormat != "json" {
|
|
||||||
log.Fatal("Invalid output format. Must be one of csv, json", "format", missingListFormat)
|
|
||||||
}
|
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{
|
|
||||||
Filters: squirrel.Eq{"missing": true},
|
|
||||||
Sort: "path",
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
err = writeMissingList(os.Stdout, missingListFormat, mfs)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to retrieve missing files", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeMissingList streams the cursor so a library with many missing files doesn't get loaded into memory
|
|
||||||
func writeMissingList(w io.Writer, format string, mfs model.MediaFileCursor) error {
|
|
||||||
if format == "json" {
|
|
||||||
bw := bufio.NewWriter(w)
|
|
||||||
_, _ = io.WriteString(bw, "[")
|
|
||||||
sep := ""
|
|
||||||
for mf, err := range mfs {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
j, _ := json.Marshal(displayMissingFile{ID: mf.ID, LibraryID: mf.LibraryID, Title: mf.Title, Album: mf.Album, Artist: mf.Artist, Path: mf.Path})
|
|
||||||
_, _ = fmt.Fprintf(bw, "%s%s", sep, j)
|
|
||||||
sep = ","
|
|
||||||
}
|
|
||||||
_, _ = io.WriteString(bw, "]\n")
|
|
||||||
return bw.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
cw := csv.NewWriter(w)
|
|
||||||
_ = cw.Write([]string{"id", "library id", "title", "album", "artist", "path"})
|
|
||||||
for mf, err := range mfs {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_ = cw.Write([]string{mf.ID, strconv.Itoa(mf.LibraryID), mf.Title, mf.Album, mf.Artist, mf.Path})
|
|
||||||
}
|
|
||||||
cw.Flush()
|
|
||||||
return cw.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMissingFix(ctx context.Context, missingRef, targetRef string) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
missing := resolveMediaFile(ctx, ds, missingRef)
|
|
||||||
target := resolveMediaFile(ctx, ds, targetRef)
|
|
||||||
|
|
||||||
if err := core.NewMaintenance(ds).RemapMissingFile(ctx, missing.ID, target.ID); err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to remap missing file", "missing", missing.Path, "target", target.Path, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Remapped %q onto %q\n", missing.Path, target.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
|
||||||
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
|
||||||
mf, err := ds.MediaFile().Get(ctx, ref)
|
|
||||||
if err == nil {
|
|
||||||
return mf
|
|
||||||
}
|
|
||||||
if !errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
|
||||||
}
|
|
||||||
if len(mfs) == 0 {
|
|
||||||
log.Fatal(ctx, "No media file found", "ref", ref)
|
|
||||||
}
|
|
||||||
mfs = preferQualified(ref, mfs)
|
|
||||||
if len(mfs) > 1 {
|
|
||||||
log.Fatal(ctx, "Path matches multiple files; disambiguate with an ID or libraryID:path", "ref", ref, "matches", len(mfs))
|
|
||||||
}
|
|
||||||
return &mfs[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// preferQualified resolves the ambiguity FindByPaths creates by searching a "libraryID:path"
|
|
||||||
// reference both ways: an explicit library wins over a file literally named like one.
|
|
||||||
func preferQualified(ref string, mfs model.MediaFiles) model.MediaFiles {
|
|
||||||
id, path, ok := strings.Cut(ref, ":")
|
|
||||||
if !ok {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
libraryID, err := strconv.Atoi(id)
|
|
||||||
if err != nil {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
qualified := slice.Filter(mfs, func(mf model.MediaFile) bool {
|
|
||||||
return mf.LibraryID == libraryID && strings.EqualFold(mf.Path, path)
|
|
||||||
})
|
|
||||||
if len(qualified) == 0 {
|
|
||||||
return mfs
|
|
||||||
}
|
|
||||||
return qualified
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("writeMissingList", func() {
|
|
||||||
cursor := func(err error, mfs ...model.MediaFile) model.MediaFileCursor {
|
|
||||||
return func(yield func(model.MediaFile, error) bool) {
|
|
||||||
for _, mf := range mfs {
|
|
||||||
if !yield(mf, nil) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
yield(model.MediaFile{}, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
song := model.MediaFile{ID: "1", LibraryID: 1, Path: "Bach: Goldberg/01.mp3", Title: "Aria", Album: "Goldberg", Artist: "Bach"}
|
|
||||||
|
|
||||||
It("writes csv with a header, quoting as needed", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "csv", cursor(nil, song))).To(Succeed())
|
|
||||||
Expect(out.String()).To(Equal("id,library id,title,album,artist,path\n1,1,Aria,Goldberg,Bach,Bach: Goldberg/01.mp3\n"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("writes a json array", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "json", cursor(nil, song, song))).To(Succeed())
|
|
||||||
Expect(out.String()).To(MatchJSON(`[
|
|
||||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"},
|
|
||||||
{"id":"1","libraryId":1,"path":"Bach: Goldberg/01.mp3","title":"Aria","album":"Goldberg","artist":"Bach"}
|
|
||||||
]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("writes an empty json array when nothing is missing", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "json", cursor(nil))).To(Succeed())
|
|
||||||
Expect(out.String()).To(MatchJSON(`[]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the cursor's error", func() {
|
|
||||||
var out strings.Builder
|
|
||||||
Expect(writeMissingList(&out, "csv", cursor(errors.New("boom"), song))).To(MatchError("boom"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("preferQualified", func() {
|
|
||||||
target := model.MediaFile{ID: "want", LibraryID: 1, Path: "foo.mp3"}
|
|
||||||
decoy := model.MediaFile{ID: "decoy", LibraryID: 1, Path: "1:foo.mp3"}
|
|
||||||
|
|
||||||
It("picks the library-qualified match over a literal path that looks like one", func() {
|
|
||||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, decoy})).To(Equal(model.MediaFiles{target}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("picks the named library when the same path exists in two", func() {
|
|
||||||
other := model.MediaFile{ID: "other", LibraryID: 2, Path: "foo.mp3"}
|
|
||||||
Expect(preferQualified("1:foo.mp3", model.MediaFiles{target, other})).To(Equal(model.MediaFiles{target}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves an unqualified reference ambiguous", func() {
|
|
||||||
both := model.MediaFiles{target, {ID: "other", LibraryID: 2, Path: "foo.mp3"}}
|
|
||||||
Expect(preferQualified("foo.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves it alone when the prefix is not a library id", func() {
|
|
||||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
|
||||||
Expect(preferQualified("x:foo.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves it alone when no candidate matches the qualified form", func() {
|
|
||||||
both := model.MediaFiles{decoy, {ID: "other", LibraryID: 2, Path: "1:foo.mp3"}}
|
|
||||||
Expect(preferQualified("9:nope.mp3", both)).To(Equal(both))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
235
cmd/pls.go
235
cmd/pls.go
|
|
@ -6,21 +6,12 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/utils/ioutils"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,7 +20,6 @@ var (
|
||||||
outputFile string
|
outputFile string
|
||||||
userID string
|
userID string
|
||||||
outputFormat string
|
outputFormat string
|
||||||
syncFlag bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type displayPlaylist struct {
|
type displayPlaylist struct {
|
||||||
|
|
@ -51,15 +41,6 @@ func init() {
|
||||||
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
||||||
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
|
||||||
plsCmd.AddCommand(listCommand)
|
plsCmd.AddCommand(listCommand)
|
||||||
|
|
||||||
exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID")
|
|
||||||
exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory")
|
|
||||||
exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID")
|
|
||||||
plsCmd.AddCommand(exportCommand)
|
|
||||||
|
|
||||||
importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)")
|
|
||||||
importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced")
|
|
||||||
plsCmd.AddCommand(importCommand)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -79,147 +60,39 @@ var (
|
||||||
runList(cmd.Context())
|
runList(cmd.Context())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
exportCommand = &cobra.Command{
|
|
||||||
Use: "export",
|
|
||||||
Short: "Export playlists to M3U files",
|
|
||||||
Long: "Export one or more Navidrome playlists to M3U files",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runExport(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
importCommand = &cobra.Command{
|
|
||||||
Use: "import [files...]",
|
|
||||||
Short: "Import M3U playlists",
|
|
||||||
Long: "Import one or more M3U files as Navidrome playlists",
|
|
||||||
Args: cobra.MinimumNArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runImport(cmd.Context(), args)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists {
|
func runExporter(ctx context.Context) {
|
||||||
options := model.QueryOptions{Sort: sort}
|
ds, ctx := getAdminContext(ctx)
|
||||||
if userID != "" {
|
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
|
||||||
user, err := getUser(ctx, userID, ds)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
|
||||||
}
|
|
||||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
|
||||||
}
|
|
||||||
pls, err := ds.Playlist().GetAll(ctx, options)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
|
||||||
}
|
|
||||||
return pls
|
|
||||||
}
|
|
||||||
|
|
||||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
|
||||||
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||||
}
|
}
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||||
}
|
}
|
||||||
if len(playlists) > 0 {
|
if len(playlists) > 0 {
|
||||||
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", playlistID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if playlist == nil {
|
if playlist == nil {
|
||||||
log.Fatal("Playlist not found", "name", nameOrID)
|
log.Fatal("Playlist not found", "name", playlistID)
|
||||||
}
|
}
|
||||||
return playlist
|
pls := playlist.ToM3U8()
|
||||||
}
|
if outputFile == "-" || outputFile == "" {
|
||||||
|
println(pls)
|
||||||
func runExporter(ctx context.Context) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
playlist := findPlaylist(ctx, ds, playlistID)
|
|
||||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writePlaylist(m3u string, out io.Writer, file string) {
|
|
||||||
if file == "" || file == "-" {
|
|
||||||
fmt.Fprint(out, m3u)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
|
|
||||||
log.Fatal("Error writing to the output file", "file", file, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runExport(ctx context.Context) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
if playlistID != "" && outputFile == "" {
|
|
||||||
playlist := findPlaylist(ctx, ds, playlistID)
|
|
||||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if outputFile == "" {
|
err = os.WriteFile(outputFile, []byte(pls), 0600)
|
||||||
log.Fatal("Output directory (-o) is required for bulk export or when filtering by user")
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := os.Stat(outputFile)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
log.Fatal("Output path must be an existing directory", "path", outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
if playlistID != "" {
|
|
||||||
pls := findPlaylist(ctx, ds, playlistID)
|
|
||||||
filename := str.SanitizeFilename(pls.Name) + ".m3u"
|
|
||||||
path := filepath.Join(outputFile, filename)
|
|
||||||
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error writing playlist", "file", path, err)
|
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||||
}
|
}
|
||||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allPls := fetchPlaylists(ctx, ds, "name")
|
|
||||||
|
|
||||||
nameCounts := make(map[string]int)
|
|
||||||
for _, pls := range allPls {
|
|
||||||
nameCounts[str.SanitizeFilename(pls.Name)]++
|
|
||||||
}
|
|
||||||
|
|
||||||
exported := 0
|
|
||||||
for _, pls := range allPls {
|
|
||||||
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized := str.SanitizeFilename(pls.Name)
|
|
||||||
filename := sanitized + ".m3u"
|
|
||||||
if nameCounts[sanitized] > 1 {
|
|
||||||
shortID := pls.ID
|
|
||||||
if len(shortID) > 6 {
|
|
||||||
shortID = shortID[:6]
|
|
||||||
}
|
|
||||||
filename = sanitized + "_" + shortID + ".m3u"
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(outputFile, filename)
|
|
||||||
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error writing playlist", "file", path, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
||||||
exported++
|
|
||||||
}
|
|
||||||
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runList(ctx context.Context) {
|
func runList(ctx context.Context) {
|
||||||
|
|
@ -228,18 +101,31 @@ func runList(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
allPls := fetchPlaylists(ctx, ds, "owner_name")
|
options := model.QueryOptions{Sort: "owner_name"}
|
||||||
|
|
||||||
|
if userID != "" {
|
||||||
|
user, err := getUser(ctx, userID, ds)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
||||||
|
}
|
||||||
|
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
playlists, err := ds.Playlist(ctx).GetAll(options)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||||
|
}
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
_ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"})
|
||||||
for _, playlist := range allPls {
|
for _, playlist := range playlists {
|
||||||
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
_ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)})
|
||||||
}
|
}
|
||||||
w.Flush()
|
w.Flush()
|
||||||
} else {
|
} else {
|
||||||
display := make(displayPlaylists, len(allPls))
|
display := make(displayPlaylists, len(playlists))
|
||||||
for idx, playlist := range allPls {
|
for idx, playlist := range playlists {
|
||||||
display[idx].Id = playlist.ID
|
display[idx].Id = playlist.ID
|
||||||
display[idx].Name = playlist.Name
|
display[idx].Name = playlist.Name
|
||||||
display[idx].OwnerId = playlist.OwnerID
|
display[idx].OwnerId = playlist.OwnerID
|
||||||
|
|
@ -251,62 +137,3 @@ func runList(ctx context.Context) {
|
||||||
fmt.Printf("%s\n", j)
|
fmt.Printf("%s\n", j)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runImport(ctx context.Context, files []string) {
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
|
|
||||||
if userID != "" {
|
|
||||||
user, err := getUser(ctx, userID, ds)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Error retrieving user", "username or id", userID)
|
|
||||||
}
|
|
||||||
ctx = request.WithUser(ctx, *user)
|
|
||||||
}
|
|
||||||
|
|
||||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
absPath, err := filepath.Abs(file)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error resolving path", "file", file, err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
totalLines := countM3UTrackLines(absPath)
|
|
||||||
|
|
||||||
imported, err := pls.ImportFile(ctx, absPath, syncFlag)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error importing playlist", "file", absPath, err)
|
|
||||||
fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
matched := len(imported.Tracks)
|
|
||||||
if totalLines > 0 {
|
|
||||||
notFound := totalLines - matched
|
|
||||||
fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func countM3UTrackLines(path string) int {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
count := 0
|
|
||||||
reader := ioutils.UTF8Reader(file)
|
|
||||||
for line := range slice.LinesFrom(reader) {
|
|
||||||
line = strings.TrimSpace(line)
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("writePlaylist", func() {
|
|
||||||
const m3u = "#EXTM3U\n#PLAYLIST:DJ Wave\n#EXTINF:364,Bel Canto - Dreaming Girl\n"
|
|
||||||
plsFile := filepath.Join(os.TempDir(), fmt.Sprintf("navidrome-pls-%d.m3u8", os.Getpid()))
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(func() { _ = os.Remove(plsFile) })
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("writes the playlist to exactly one destination",
|
|
||||||
func(file, wantStream, wantFile string) {
|
|
||||||
var out strings.Builder
|
|
||||||
|
|
||||||
writePlaylist(m3u, &out, file)
|
|
||||||
|
|
||||||
written, _ := os.ReadFile(plsFile)
|
|
||||||
Expect(out.String()).To(Equal(wantStream))
|
|
||||||
Expect(string(written)).To(Equal(wantFile))
|
|
||||||
},
|
|
||||||
Entry("no file name writes to the stream", "", m3u, ""),
|
|
||||||
Entry("a dash writes to the stream", "-", m3u, ""),
|
|
||||||
Entry("a path writes to the file", plsFile, "", m3u),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
557
cmd/plugin.go
557
cmd/plugin.go
|
|
@ -1,557 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/csv"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/plugins"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
// pluginManager is the subset of *plugins.Manager the CLI needs.
|
|
||||||
type pluginManager interface {
|
|
||||||
EnablePlugin(ctx context.Context, id string) error
|
|
||||||
DisablePlugin(ctx context.Context, id string) error
|
|
||||||
ValidatePluginConfig(ctx context.Context, id, configJSON string) error
|
|
||||||
UpdatePluginConfig(ctx context.Context, id, configJSON string) error
|
|
||||||
UpdatePluginUsers(ctx context.Context, id, usersJSON string, allUsers bool) error
|
|
||||||
UpdatePluginLibraries(ctx context.Context, id, librariesJSON string, allLibraries, allowWriteAccess bool) error
|
|
||||||
RescanPlugins(ctx context.Context) error
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
pluginListFormat string
|
|
||||||
pluginInfoFormat string
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
editConfig string
|
|
||||||
editConfigFile string
|
|
||||||
editUsers string
|
|
||||||
editAllUsers bool
|
|
||||||
editLibraries string
|
|
||||||
editAllLibs bool
|
|
||||||
editWriteAccess bool
|
|
||||||
editNoWrite bool
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(pluginRoot)
|
|
||||||
|
|
||||||
pluginListCmd.Flags().StringVarP(&pluginListFormat, "format", "f", "table", "output format [supported values: table, csv, json]")
|
|
||||||
pluginRoot.AddCommand(pluginListCmd)
|
|
||||||
pluginRoot.AddCommand(pluginEnableCmd)
|
|
||||||
pluginRoot.AddCommand(pluginDisableCmd)
|
|
||||||
|
|
||||||
pluginEditCmd.Flags().StringVar(&editConfig, "config", "", "plugin config as JSON")
|
|
||||||
pluginEditCmd.Flags().StringVar(&editConfigFile, "config-file", "", "read plugin config JSON from a file ('-' for stdin)")
|
|
||||||
pluginEditCmd.MarkFlagsMutuallyExclusive("config", "config-file")
|
|
||||||
pluginEditCmd.Flags().StringVar(&editUsers, "users", "", `usernames the plugin may access: comma-separated (alice,bob) or a JSON array (["alice","bob"])`)
|
|
||||||
pluginEditCmd.Flags().BoolVar(&editAllUsers, "all-users", false, "grant the plugin access to all users")
|
|
||||||
pluginEditCmd.MarkFlagsMutuallyExclusive("users", "all-users")
|
|
||||||
pluginEditCmd.Flags().StringVar(&editLibraries, "libraries", "", `library IDs the plugin may access: comma-separated (1,2) or a JSON array ([1,2])`)
|
|
||||||
pluginEditCmd.Flags().BoolVar(&editAllLibs, "all-libraries", false, "grant the plugin access to all libraries")
|
|
||||||
pluginEditCmd.MarkFlagsMutuallyExclusive("libraries", "all-libraries")
|
|
||||||
pluginEditCmd.Flags().BoolVar(&editWriteAccess, "write-access", false, "allow the plugin write access to libraries")
|
|
||||||
pluginEditCmd.Flags().BoolVar(&editNoWrite, "no-write-access", false, "deny the plugin write access to libraries")
|
|
||||||
pluginEditCmd.MarkFlagsMutuallyExclusive("write-access", "no-write-access")
|
|
||||||
pluginRoot.AddCommand(pluginEditCmd)
|
|
||||||
|
|
||||||
pluginInfoCmd.Flags().StringVarP(&pluginInfoFormat, "format", "f", "text", "output format [supported values: text, json]")
|
|
||||||
pluginRoot.AddCommand(pluginInfoCmd)
|
|
||||||
pluginRoot.AddCommand(pluginValidateCmd)
|
|
||||||
pluginRoot.AddCommand(pluginRescanCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
pluginRoot = &cobra.Command{
|
|
||||||
Use: "plugin",
|
|
||||||
Short: "Manage and inspect plugins",
|
|
||||||
Long: "List, inspect, enable, disable, configure, rescan, and validate plugins",
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginListCmd = &cobra.Command{
|
|
||||||
Use: "list",
|
|
||||||
Short: "List installed plugins",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runPluginList(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginEnableCmd = &cobra.Command{
|
|
||||||
Use: "enable <id>",
|
|
||||||
Short: "Enable a plugin",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
requirePluginsEnabled(cmd.Context())
|
|
||||||
_, ctx := getAdminContext(cmd.Context())
|
|
||||||
mgr := GetPluginManager(ctx)
|
|
||||||
if err := enablePlugin(ctx, mgr, args[0]); err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to enable plugin", "id", args[0], err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginDisableCmd = &cobra.Command{
|
|
||||||
Use: "disable <id>",
|
|
||||||
Short: "Disable a plugin",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
requirePluginsEnabled(cmd.Context())
|
|
||||||
_, ctx := getAdminContext(cmd.Context())
|
|
||||||
mgr := GetPluginManager(ctx)
|
|
||||||
if err := disablePlugin(ctx, mgr, args[0]); err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to disable plugin", "id", args[0], err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
pluginInfoCmd = &cobra.Command{
|
|
||||||
Use: "info <id|file.ndp>",
|
|
||||||
Short: "Show details for an installed plugin or a .ndp package",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runPluginInfo(cmd.Context(), args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pluginValidateCmd = &cobra.Command{
|
|
||||||
Use: "validate <id|file.ndp>",
|
|
||||||
Short: "Validate an installed plugin or a .ndp package manifest",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
runPluginValidate(cmd.Context(), args[0])
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// isPackagePath checks only the extension (not existence) so a mistyped path
|
|
||||||
// still routes to ReadManifest, which reports a precise "no such file" error.
|
|
||||||
func isPackagePath(arg string) bool {
|
|
||||||
return strings.HasSuffix(arg, plugins.PackageExtension)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatPluginInfo(p *model.Plugin, format string) (string, error) {
|
|
||||||
switch format {
|
|
||||||
case "json":
|
|
||||||
b, err := json.MarshalIndent(p, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
case "text":
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
|
||||||
}
|
|
||||||
name, version := manifestSummary(*p)
|
|
||||||
var sb strings.Builder
|
|
||||||
fmt.Fprintf(&sb, "ID: %s\n", p.ID)
|
|
||||||
fmt.Fprintf(&sb, "Name: %s\n", name)
|
|
||||||
fmt.Fprintf(&sb, "Version: %s\n", version)
|
|
||||||
fmt.Fprintf(&sb, "Enabled: %t\n", p.Enabled)
|
|
||||||
fmt.Fprintf(&sb, "Path: %s\n", p.Path)
|
|
||||||
fmt.Fprintf(&sb, "SHA256: %s\n", p.SHA256)
|
|
||||||
fmt.Fprintf(&sb, "All users: %t\n", p.AllUsers)
|
|
||||||
fmt.Fprintf(&sb, "All libs: %t\n", p.AllLibraries)
|
|
||||||
fmt.Fprintf(&sb, "Write access: %t\n", p.AllowWriteAccess)
|
|
||||||
if p.Users != "" {
|
|
||||||
fmt.Fprintf(&sb, "Users: %s\n", p.Users)
|
|
||||||
}
|
|
||||||
if p.Libraries != "" {
|
|
||||||
fmt.Fprintf(&sb, "Libraries: %s\n", p.Libraries)
|
|
||||||
}
|
|
||||||
if m, err := plugins.ParseManifest([]byte(p.Manifest)); err == nil {
|
|
||||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
|
||||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !p.CreatedAt.IsZero() {
|
|
||||||
fmt.Fprintf(&sb, "Created: %s\n", p.CreatedAt.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
if !p.UpdatedAt.IsZero() {
|
|
||||||
fmt.Fprintf(&sb, "Updated: %s\n", p.UpdatedAt.Format(time.RFC3339))
|
|
||||||
}
|
|
||||||
if p.Config != "" {
|
|
||||||
fmt.Fprintf(&sb, "Config: %s\n", p.Config)
|
|
||||||
}
|
|
||||||
if p.LastError != "" {
|
|
||||||
fmt.Fprintf(&sb, "Last error: %s\n", p.LastError)
|
|
||||||
}
|
|
||||||
return sb.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatManifestInfo(m *plugins.Manifest, sha256, format string) (string, error) {
|
|
||||||
switch format {
|
|
||||||
case "json":
|
|
||||||
b, err := json.MarshalIndent(struct {
|
|
||||||
*plugins.Manifest
|
|
||||||
SHA256 string `json:"sha256"`
|
|
||||||
}{m, sha256}, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
case "text":
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("invalid output format %q (supported: text, json)", format)
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
fmt.Fprintf(&sb, "Name: %s\n", m.Name)
|
|
||||||
fmt.Fprintf(&sb, "Version: %s\n", m.Version)
|
|
||||||
fmt.Fprintf(&sb, "Author: %s\n", m.Author)
|
|
||||||
if m.Description != nil {
|
|
||||||
fmt.Fprintf(&sb, "Description: %s\n", *m.Description)
|
|
||||||
}
|
|
||||||
if m.Website != nil {
|
|
||||||
fmt.Fprintf(&sb, "Website: %s\n", *m.Website)
|
|
||||||
}
|
|
||||||
if perms := m.Permissions.DeclaredNames(); len(perms) > 0 {
|
|
||||||
fmt.Fprintf(&sb, "Permissions: %s\n", strings.Join(perms, ", "))
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&sb, "SHA256: %s\n", sha256)
|
|
||||||
return sb.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPluginInfo(ctx context.Context, arg string) {
|
|
||||||
if isPackagePath(arg) {
|
|
||||||
m, err := plugins.ReadManifest(arg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to read package", "path", arg, err)
|
|
||||||
}
|
|
||||||
sha, err := plugins.ComputeFileSHA256(arg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to hash package", "path", arg, err)
|
|
||||||
}
|
|
||||||
out, err := formatManifestInfo(m, sha, pluginInfoFormat)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to format output", err)
|
|
||||||
}
|
|
||||||
fmt.Print(out)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requirePluginsEnabled(ctx)
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
p, err := ds.Plugin().Get(ctx, arg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
|
||||||
}
|
|
||||||
out, err := formatPluginInfo(p, pluginInfoFormat)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to format output", err)
|
|
||||||
}
|
|
||||||
fmt.Print(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPluginValidate(ctx context.Context, arg string) {
|
|
||||||
if isPackagePath(arg) {
|
|
||||||
if _, err := plugins.ReadManifest(arg); err != nil {
|
|
||||||
log.Fatal(ctx, "Validation failed", "path", arg, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: OK\n", arg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
requirePluginsEnabled(ctx)
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
p, err := ds.Plugin().Get(ctx, arg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
|
||||||
}
|
|
||||||
if _, err := plugins.ParseManifest([]byte(p.Manifest)); err != nil {
|
|
||||||
log.Fatal(ctx, "Validation failed", "id", arg, err)
|
|
||||||
}
|
|
||||||
if p.Config != "" {
|
|
||||||
mgr := GetPluginManager(ctx)
|
|
||||||
if err := mgr.ValidatePluginConfig(ctx, arg, p.Config); err != nil {
|
|
||||||
log.Fatal(ctx, "Config validation failed", "id", arg, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fmt.Printf("%s: OK\n", arg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// manifestSummary extracts the display name and version from a stored manifest JSON, falling
|
|
||||||
// back to the plugin ID when the manifest can't be parsed.
|
|
||||||
func manifestSummary(p model.Plugin) (name, version string) {
|
|
||||||
var m struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Version string `json:"version"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(p.Manifest), &m); err != nil {
|
|
||||||
return p.ID, ""
|
|
||||||
}
|
|
||||||
return m.Name, m.Version
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatPluginList(list model.Plugins, format string) (string, error) {
|
|
||||||
switch format {
|
|
||||||
case "json":
|
|
||||||
b, err := json.MarshalIndent(list, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
case "csv":
|
|
||||||
var sb strings.Builder
|
|
||||||
w := csv.NewWriter(&sb)
|
|
||||||
_ = w.Write([]string{"id", "name", "version", "enabled", "last error"})
|
|
||||||
for _, p := range list {
|
|
||||||
name, version := manifestSummary(p)
|
|
||||||
_ = w.Write([]string{p.ID, name, version, fmt.Sprintf("%t", p.Enabled), p.LastError})
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
return sb.String(), w.Error()
|
|
||||||
case "table":
|
|
||||||
var sb strings.Builder
|
|
||||||
w := newTabWriter(&sb)
|
|
||||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
|
||||||
for _, p := range list {
|
|
||||||
name, version := manifestSummary(p)
|
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%t\t%s\n", p.ID, name, version, p.Enabled, p.LastError)
|
|
||||||
}
|
|
||||||
w.Flush()
|
|
||||||
return sb.String(), nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("invalid output format %q (supported: table, csv, json)", format)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPluginList(ctx context.Context) {
|
|
||||||
requirePluginsEnabled(ctx)
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
|
||||||
list, err := ds.Plugin().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to list plugins", err)
|
|
||||||
}
|
|
||||||
out, err := formatPluginList(list, pluginListFormat)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to format output", err)
|
|
||||||
}
|
|
||||||
fmt.Print(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp
|
|
||||||
// inspection deliberately skips this so it works without a configured server.
|
|
||||||
func requirePluginsEnabled(ctx context.Context) {
|
|
||||||
if !conf.Server.Plugins.Enabled {
|
|
||||||
log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func enablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
|
||||||
return mgr.EnablePlugin(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func disablePlugin(ctx context.Context, mgr pluginManager, id string) error {
|
|
||||||
return mgr.DisablePlugin(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
type pluginEditOptions struct {
|
|
||||||
config *string // nil = leave unchanged
|
|
||||||
users *string
|
|
||||||
allUsers *bool
|
|
||||||
libraries *string
|
|
||||||
allLibraries *bool
|
|
||||||
writeAccess *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var pluginEditCmd = &cobra.Command{
|
|
||||||
Use: "edit <id>",
|
|
||||||
Short: "Update a plugin's config and/or permissions",
|
|
||||||
Args: cobra.ExactArgs(1),
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
requirePluginsEnabled(cmd.Context())
|
|
||||||
ds, ctx := getAdminContext(cmd.Context())
|
|
||||||
cur, err := ds.Plugin().Get(ctx, args[0])
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
|
||||||
}
|
|
||||||
mgr := GetPluginManager(ctx)
|
|
||||||
opts := buildEditOptionsFromFlags(ctx, cmd)
|
|
||||||
if err := applyPluginEdit(ctx, mgr, cur, opts); err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to edit plugin", "id", args[0], err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildEditOptionsFromFlags(ctx context.Context, cmd *cobra.Command) pluginEditOptions {
|
|
||||||
var opts pluginEditOptions
|
|
||||||
switch {
|
|
||||||
case cmd.Flags().Changed("config"):
|
|
||||||
c := editConfig
|
|
||||||
opts.config = &c
|
|
||||||
case cmd.Flags().Changed("config-file"):
|
|
||||||
c := readConfigFile(ctx, editConfigFile)
|
|
||||||
opts.config = &c
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("users") {
|
|
||||||
u := editUsers
|
|
||||||
opts.users = &u
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("all-users") {
|
|
||||||
v := editAllUsers
|
|
||||||
opts.allUsers = &v
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("libraries") {
|
|
||||||
l := editLibraries
|
|
||||||
opts.libraries = &l
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("all-libraries") {
|
|
||||||
v := editAllLibs
|
|
||||||
opts.allLibraries = &v
|
|
||||||
}
|
|
||||||
if cmd.Flags().Changed("write-access") || cmd.Flags().Changed("no-write-access") {
|
|
||||||
// write-access is part of the library-permission group, so it is updated
|
|
||||||
// alongside the (preserved) library list rather than on its own.
|
|
||||||
wa := editWriteAccess && !editNoWrite
|
|
||||||
opts.writeAccess = &wa
|
|
||||||
}
|
|
||||||
return opts
|
|
||||||
}
|
|
||||||
|
|
||||||
func readConfigFile(ctx context.Context, path string) string {
|
|
||||||
var data []byte
|
|
||||||
var err error
|
|
||||||
if path == "-" {
|
|
||||||
data, err = io.ReadAll(os.Stdin)
|
|
||||||
} else {
|
|
||||||
data, err = os.ReadFile(path)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to read config file", "path", path, err)
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyPluginEdit applies the requested changes on top of the plugin's current
|
|
||||||
// state. Like the native API, it reads the existing users/libraries before
|
|
||||||
// updating so that flipping one flag (e.g. --write-access) does not wipe
|
|
||||||
// unspecified fields, and rejects non-JSON users/libraries values.
|
|
||||||
func applyPluginEdit(ctx context.Context, mgr pluginManager, cur *model.Plugin, opts pluginEditOptions) error {
|
|
||||||
if opts.config == nil && opts.users == nil && opts.allUsers == nil &&
|
|
||||||
opts.libraries == nil && opts.allLibraries == nil && opts.writeAccess == nil {
|
|
||||||
return fmt.Errorf("nothing to update: provide at least one of --config/--users/--libraries/--write-access")
|
|
||||||
}
|
|
||||||
id := cur.ID
|
|
||||||
if opts.config != nil {
|
|
||||||
if err := mgr.ValidatePluginConfig(ctx, id, *opts.config); err != nil {
|
|
||||||
return fmt.Errorf("invalid config: %w", err)
|
|
||||||
}
|
|
||||||
if err := mgr.UpdatePluginConfig(ctx, id, *opts.config); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if opts.users != nil || opts.allUsers != nil {
|
|
||||||
users, allUsers := cur.Users, cur.AllUsers
|
|
||||||
if opts.users != nil {
|
|
||||||
parsed, err := usersToJSON(*opts.users)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
users = parsed
|
|
||||||
allUsers = false // an explicit list means "restrict to these users"
|
|
||||||
}
|
|
||||||
if opts.allUsers != nil {
|
|
||||||
allUsers = *opts.allUsers
|
|
||||||
}
|
|
||||||
if err := mgr.UpdatePluginUsers(ctx, id, users, allUsers); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if opts.libraries != nil || opts.allLibraries != nil || opts.writeAccess != nil {
|
|
||||||
libs, allLibs, writeAccess := cur.Libraries, cur.AllLibraries, cur.AllowWriteAccess
|
|
||||||
if opts.libraries != nil {
|
|
||||||
parsed, err := librariesToJSON(*opts.libraries)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
libs = parsed
|
|
||||||
allLibs = false // an explicit list means "restrict to these libraries"
|
|
||||||
}
|
|
||||||
if opts.allLibraries != nil {
|
|
||||||
allLibs = *opts.allLibraries
|
|
||||||
}
|
|
||||||
if opts.writeAccess != nil {
|
|
||||||
writeAccess = *opts.writeAccess
|
|
||||||
}
|
|
||||||
if err := mgr.UpdatePluginLibraries(ctx, id, libs, allLibs, writeAccess); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// usersToJSON accepts either a JSON array (starts with '[') or a comma-separated
|
|
||||||
// list and returns the JSON-array form the manager stores.
|
|
||||||
func usersToJSON(value string) (string, error) {
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return "[]", nil
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
|
||||||
if !json.Valid([]byte(value)) {
|
|
||||||
return "", fmt.Errorf("invalid JSON in --users")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
var names []string
|
|
||||||
for _, u := range strings.Split(value, ",") {
|
|
||||||
if u = strings.TrimSpace(u); u != "" {
|
|
||||||
names = append(names, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(names)
|
|
||||||
return string(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// librariesToJSON accepts either a JSON array (starts with '[') or a
|
|
||||||
// comma-separated list of integer IDs and returns the JSON-array form stored.
|
|
||||||
func librariesToJSON(value string) (string, error) {
|
|
||||||
if strings.TrimSpace(value) == "" {
|
|
||||||
return "[]", nil
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(value), "[") {
|
|
||||||
if !json.Valid([]byte(value)) {
|
|
||||||
return "", fmt.Errorf("invalid JSON in --libraries")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
ids := []int{}
|
|
||||||
for _, l := range strings.Split(value, ",") {
|
|
||||||
if l = strings.TrimSpace(l); l != "" {
|
|
||||||
id, err := strconv.Atoi(l)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("invalid library ID %q: must be an integer", l)
|
|
||||||
}
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b, _ := json.Marshal(ids)
|
|
||||||
return string(b), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var pluginRescanCmd = &cobra.Command{
|
|
||||||
Use: "rescan",
|
|
||||||
Short: "Re-discover plugins in the plugins folder",
|
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
|
||||||
requirePluginsEnabled(cmd.Context())
|
|
||||||
_, ctx := getAdminContext(cmd.Context())
|
|
||||||
mgr := GetPluginManager(ctx)
|
|
||||||
if err := rescanPlugins(ctx, mgr); err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to rescan plugins", err)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func rescanPlugins(ctx context.Context, mgr pluginManager) error {
|
|
||||||
return mgr.RescanPlugins(ctx)
|
|
||||||
}
|
|
||||||
|
|
@ -1,360 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/plugins"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var samplePlugins = model.Plugins{
|
|
||||||
{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true},
|
|
||||||
{ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = Describe("plugin command format flags", func() {
|
|
||||||
// Regression: list and info must not share a format variable. Binding the
|
|
||||||
// same var on both commands makes the last init() registration clobber the
|
|
||||||
// other's default, breaking `plugin list` with no -f flag.
|
|
||||||
It("defaults `list -f` to table", func() {
|
|
||||||
Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("defaults `info -f` to text", func() {
|
|
||||||
Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatPluginList", func() {
|
|
||||||
It("renders csv with a header and one row per plugin", func() {
|
|
||||||
out, err := formatPluginList(samplePlugins, "csv")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
|
||||||
Expect(lines).To(HaveLen(3)) // header + 2 rows
|
|
||||||
Expect(lines[0]).To(ContainSubstring("id"))
|
|
||||||
Expect(out).To(ContainSubstring("alpha"))
|
|
||||||
Expect(out).To(ContainSubstring("beta"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("renders valid json", func() {
|
|
||||||
out, err := formatPluginList(samplePlugins, "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
var got []map[string]any
|
|
||||||
Expect(json.Unmarshal([]byte(out), &got)).To(Succeed())
|
|
||||||
Expect(got).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("renders a human table by default", func() {
|
|
||||||
out, err := formatPluginList(samplePlugins, "table")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("Alpha"))
|
|
||||||
Expect(out).To(ContainSubstring("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("errors on an unknown format", func() {
|
|
||||||
_, err := formatPluginList(samplePlugins, "yaml")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("enable/disable plugin", func() {
|
|
||||||
It("calls EnablePlugin on the manager", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
err := enablePlugin(context.Background(), mgr, "alpha")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("calls DisablePlugin on the manager", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
err := disablePlugin(context.Background(), mgr, "beta")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.DisablePluginCalls).To(Equal([]string{"beta"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("applyPluginEdit", func() {
|
|
||||||
var cur *model.Plugin
|
|
||||||
BeforeEach(func() {
|
|
||||||
cur = &model.Plugin{ID: "alpha", Users: `["bob"]`, Libraries: `[1,2]`, AllowWriteAccess: true}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("validates then updates config when config is provided", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
cfg := `{"key":"val"}`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.ValidatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
|
||||||
Expect(mgr.UpdatePluginConfigCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginConfigCalls[0].ConfigJSON).To(Equal(cfg))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("updates users with allUsers flag", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
all := true
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
|
||||||
pluginEditOptions{allUsers: &all})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("updates libraries with allLibraries and write access", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
all := true
|
|
||||||
wr := true
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur,
|
|
||||||
pluginEditOptions{allLibraries: &all, writeAccess: &wr})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeTrue())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("preserves existing fields when only the write-access flag changes", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
no := false
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{writeAccess: &no})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`)) // not wiped
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllowWriteAccess).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("preserves existing users when only the all-users flag changes", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
all := true
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{allUsers: &all})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["bob"]`)) // not wiped
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses a comma-separated users value into a JSON array", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
users := "alice, bob"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes a JSON-array users value through unchanged", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
users := `["alice","bob"]`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice","bob"]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a malformed JSON-array users value", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
users := `["alice"`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses a comma-separated libraries value into a JSON array of ints", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
libs := "1, 2"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1,2]`))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a non-integer library ID", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
libs := "1,abc"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("clears allUsers when an explicit users list is set", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
cur.AllUsers = true
|
|
||||||
users := "alice"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{users: &users})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].UsersJSON).To(Equal(`["alice"]`))
|
|
||||||
Expect(mgr.UpdatePluginUsersCalls[0].AllUsers).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("clears allLibraries when an explicit libraries list is set", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
cur.AllLibraries = true
|
|
||||||
libs := "1"
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{libraries: &libs})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].LibrariesJSON).To(Equal(`[1]`))
|
|
||||||
Expect(mgr.UpdatePluginLibrariesCalls[0].AllLibraries).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does nothing and errors when no fields are set", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("aborts the config update when validation fails", func() {
|
|
||||||
mgr := &tests.MockPluginManager{ValidateError: errors.New("bad config")}
|
|
||||||
cfg := `{"key":"val"}`
|
|
||||||
err := applyPluginEdit(context.Background(), mgr, cur, pluginEditOptions{config: &cfg})
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(mgr.ValidatePluginConfigCalls).To(HaveLen(1))
|
|
||||||
Expect(mgr.UpdatePluginConfigCalls).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("isPackagePath", func() {
|
|
||||||
It("is true for any .ndp path", func() {
|
|
||||||
Expect(isPackagePath("/some/dir/x.ndp")).To(BeTrue())
|
|
||||||
})
|
|
||||||
It("is true for a non-existent .ndp path (so ReadManifest reports the error)", func() {
|
|
||||||
Expect(isPackagePath("/nope/x.ndp")).To(BeTrue())
|
|
||||||
})
|
|
||||||
It("is false for a bare plugin id", func() {
|
|
||||||
Expect(isPackagePath("my-plugin")).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatPluginInfo", func() {
|
|
||||||
It("renders installed plugin details as text", func() {
|
|
||||||
p := &model.Plugin{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true}
|
|
||||||
out, err := formatPluginInfo(p, "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("alpha"))
|
|
||||||
Expect(out).To(ContainSubstring("Alpha"))
|
|
||||||
})
|
|
||||||
It("renders json", func() {
|
|
||||||
p := &model.Plugin{ID: "alpha", Manifest: `{}`}
|
|
||||||
out, err := formatPluginInfo(p, "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("alpha"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatManifestInfo", func() {
|
|
||||||
It("renders text with name, version, author", func() {
|
|
||||||
m := &plugins.Manifest{Name: "My Plugin", Version: "2.0.0", Author: "me"}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("My Plugin"))
|
|
||||||
Expect(out).To(ContainSubstring("2.0.0"))
|
|
||||||
Expect(out).To(ContainSubstring("me"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("omits Description and Website when nil", func() {
|
|
||||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).ToNot(ContainSubstring("Description:"))
|
|
||||||
Expect(out).ToNot(ContainSubstring("Website:"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("includes Description and Website when set", func() {
|
|
||||||
desc := "a cool plugin"
|
|
||||||
site := "https://example.com"
|
|
||||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a", Description: &desc, Website: &site}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("a cool plugin"))
|
|
||||||
Expect(out).To(ContainSubstring("https://example.com"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("renders valid json", func() {
|
|
||||||
m := &plugins.Manifest{Name: "X", Version: "1.0.0", Author: "a"}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("\"name\""))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatPluginInfo enriched text", func() {
|
|
||||||
var fixedTime time.Time
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("includes Users, Libraries, Permissions, Created, Updated in text output", func() {
|
|
||||||
p := &model.Plugin{
|
|
||||||
ID: "myplugin",
|
|
||||||
Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`,
|
|
||||||
Enabled: true,
|
|
||||||
Users: "alice,bob",
|
|
||||||
Libraries: "1,2",
|
|
||||||
CreatedAt: fixedTime,
|
|
||||||
UpdatedAt: fixedTime.Add(time.Hour),
|
|
||||||
}
|
|
||||||
out, err := formatPluginInfo(p, "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("alice,bob"))
|
|
||||||
Expect(out).To(ContainSubstring("1,2"))
|
|
||||||
Expect(out).To(ContainSubstring("subsonicapi"))
|
|
||||||
Expect(out).To(ContainSubstring("users"))
|
|
||||||
Expect(out).To(ContainSubstring("2025-01-15T12:00:00Z"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("omits Users and Libraries lines when empty", func() {
|
|
||||||
p := &model.Plugin{
|
|
||||||
ID: "myplugin",
|
|
||||||
Manifest: `{"name":"X","version":"1.0.0","author":"me"}`,
|
|
||||||
CreatedAt: fixedTime,
|
|
||||||
UpdatedAt: fixedTime,
|
|
||||||
}
|
|
||||||
out, err := formatPluginInfo(p, "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).ToNot(ContainSubstring("Users:"))
|
|
||||||
Expect(out).ToNot(ContainSubstring("Libraries:"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not alter json output", func() {
|
|
||||||
p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"}
|
|
||||||
out, err := formatPluginInfo(p, "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring(`"x"`))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatManifestInfo enriched text", func() {
|
|
||||||
It("includes Permissions when declared", func() {
|
|
||||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring("http"))
|
|
||||||
Expect(out).To(ContainSubstring("Permissions:"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("omits Permissions line when no permissions declared", func() {
|
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).ToNot(ContainSubstring("Permissions:"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not alter json output", func() {
|
|
||||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "json")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring(`"name"`))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("rescanPlugins", func() {
|
|
||||||
It("calls RescanPlugins on the manager", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
|
||||||
err := rescanPlugins(context.Background(), mgr)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(mgr.RescanPluginsCalls).To(Equal(1))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
169
cmd/root.go
169
cmd/root.go
|
|
@ -2,7 +2,6 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -12,7 +11,6 @@ import (
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -29,6 +27,8 @@ import (
|
||||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
||||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
||||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/spotify"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -44,9 +44,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||||
preRun()
|
preRun()
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if err := runNavidrome(cmd.Context()); err != nil {
|
runNavidrome(cmd.Context())
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
PostRun: func(cmd *cobra.Command, args []string) {
|
PostRun: func(cmd *cobra.Command, args []string) {
|
||||||
postRun()
|
postRun()
|
||||||
|
|
@ -78,24 +76,20 @@ func postRun() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||||
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
// it will cancel the context and exit gracefully.
|
||||||
func runNavidrome(parentCtx context.Context) error {
|
func runNavidrome(ctx context.Context) {
|
||||||
defer db.Init(parentCtx)()
|
defer db.Init(ctx)()
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(parentCtx)
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
g.Go(startServer(ctx))
|
g.Go(startServer(ctx))
|
||||||
g.Go(startSignaller(ctx))
|
g.Go(startSignaller(ctx))
|
||||||
g.Go(startScheduler(ctx))
|
g.Go(startScheduler(ctx))
|
||||||
g.Go(startPlaybackServer(ctx))
|
g.Go(startPlaybackServer(ctx))
|
||||||
g.Go(startJellyfinDiscovery(ctx))
|
|
||||||
g.Go(schedulePeriodicBackup(ctx))
|
g.Go(schedulePeriodicBackup(ctx))
|
||||||
g.Go(startInsightsCollector(ctx))
|
g.Go(startInsightsCollector(ctx))
|
||||||
g.Go(scheduleDBAnalyzer(ctx))
|
g.Go(scheduleDBOptimizer(ctx))
|
||||||
g.Go(startPluginManager(ctx))
|
g.Go(startPluginManager(ctx))
|
||||||
artworkWorker := CreateArtworkWorker()
|
|
||||||
g.Go(startArtworkWorker(ctx, artworkWorker))
|
|
||||||
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
|
|
||||||
g.Go(runInitialScan(ctx))
|
g.Go(runInitialScan(ctx))
|
||||||
if conf.Server.Scanner.Enabled {
|
if conf.Server.Scanner.Enabled {
|
||||||
g.Go(startScanWatcher(ctx))
|
g.Go(startScanWatcher(ctx))
|
||||||
|
|
@ -104,11 +98,9 @@ func runNavidrome(parentCtx context.Context) error {
|
||||||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors caused by a normal shutdown are not failures
|
if err := g.Wait(); err != nil {
|
||||||
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||||
|
|
@ -134,12 +126,6 @@ func startServer(ctx context.Context) func() error {
|
||||||
if conf.Server.ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||||
}
|
}
|
||||||
if conf.Server.Jellyfin.Enabled {
|
|
||||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
|
||||||
}
|
|
||||||
if conf.Server.DevAPIv1 {
|
|
||||||
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
|
||||||
}
|
|
||||||
if conf.Server.Prometheus.Enabled {
|
if conf.Server.Prometheus.Enabled {
|
||||||
p := CreatePrometheus()
|
p := CreatePrometheus()
|
||||||
// blocking call because takes <100ms but useful if fails
|
// blocking call because takes <100ms but useful if fails
|
||||||
|
|
@ -147,7 +133,7 @@ func startServer(ctx context.Context) func() error {
|
||||||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
||||||
}
|
}
|
||||||
if conf.Server.DevEnableProfiler {
|
if conf.Server.DevEnableProfiler {
|
||||||
a.MountRouter("Profiling", "/debug", profilerHandler())
|
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||||
|
|
@ -156,14 +142,6 @@ func startServer(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// profilerHandler returns the pprof handler. net/http/pprof resolves the profile
|
|
||||||
// name from the raw request path, so the BasePath has to come off first.
|
|
||||||
func profilerHandler() http.Handler {
|
|
||||||
// A trailing or root slash would make StripPrefix drop the leading slash chi needs.
|
|
||||||
basePath := strings.TrimRight(conf.Server.BasePath, "/")
|
|
||||||
return http.StripPrefix(basePath, middleware.Profiler())
|
|
||||||
}
|
|
||||||
|
|
||||||
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
|
|
@ -190,50 +168,45 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||||
// the one used by their last finished scan
|
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||||
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
|
||||||
libs, err := ds.Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
var names []string
|
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||||
for _, lib := range libs {
|
if err != nil {
|
||||||
if lib.PIDChanged() {
|
return false, err
|
||||||
names = append(names, lib.Name)
|
|
||||||
}
|
}
|
||||||
}
|
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
||||||
return names, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInitialScan runs an initial scan of the music library if needed.
|
// runInitialScan runs an initial scan of the music library if needed.
|
||||||
func runInitialScan(ctx context.Context) func() error {
|
func runInitialScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
ds := CreateDataStore()
|
ds := CreateDataStore()
|
||||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
pidHasChanged, err := pidHashChanged(ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
switch {
|
switch {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||||
case len(pidChangedLibs) > 0:
|
case pidHasChanged:
|
||||||
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
log.Warn(ctx, "PID config changed, performing full scan")
|
||||||
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
fullScanRequired = "1"
|
||||||
case inProgress:
|
case inProgress:
|
||||||
log.Warn(ctx, "Resuming interrupted scan")
|
log.Warn(ctx, "Resuming interrupted scan")
|
||||||
default:
|
default:
|
||||||
|
|
@ -303,24 +276,16 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
func scheduleDBOptimizer(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
if !conf.Server.EnableScheduledDBAnalyze {
|
log.Info(ctx, "Scheduling DB optimizer", "schedule", consts.OptimizeDBSchedule)
|
||||||
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
log.Info(ctx, "Scheduling DB analysis check", "schedule", consts.DBAnalyzeCheckSchedule)
|
|
||||||
schedulerInstance := scheduler.GetInstance()
|
schedulerInstance := scheduler.GetInstance()
|
||||||
_, err := schedulerInstance.Add(consts.DBAnalyzeCheckSchedule, func() {
|
_, err := schedulerInstance.Add(consts.OptimizeDBSchedule, func() {
|
||||||
release, ok := scanner.LockForMaintenance()
|
if scanner.IsScanning() {
|
||||||
if !ok {
|
log.Debug(ctx, "Skipping DB optimization because a scan is in progress")
|
||||||
log.Debug(ctx, "Skipping DB analysis check because a scan is in progress")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer release()
|
db.Optimize(ctx)
|
||||||
if _, err := db.OptimizeIfNeeded(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error analyzing DB", err)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -355,18 +320,6 @@ func startInsightsCollector(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
|
|
||||||
func startJellyfinDiscovery(ctx context.Context) func() error {
|
|
||||||
return func() error {
|
|
||||||
if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
|
|
||||||
log.Debug("Jellyfin auto-discovery is DISABLED")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
CreateJellyfinDiscovery().Serve(ctx)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||||
// It is responsible for the Jukebox functionality
|
// It is responsible for the Jukebox functionality
|
||||||
func startPlaybackServer(ctx context.Context) func() error {
|
func startPlaybackServer(ctx context.Context) func() error {
|
||||||
|
|
@ -381,60 +334,6 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startArtworkWorker starts the background artwork acquisition worker. It always
|
|
||||||
// runs; the queue is simply empty until something enqueues work into it.
|
|
||||||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
|
||||||
return func() error {
|
|
||||||
log.Info(ctx, "Starting artwork worker")
|
|
||||||
// The scanner writes to the DB for its whole run; competing for the write lock makes both fail.
|
|
||||||
worker.PauseWhile(scanner.IsScanning)
|
|
||||||
return worker.Run(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// outsideScan runs a DB maintenance job unless a scan is running, and keeps a scan from starting
|
|
||||||
// until it ends; both write to the DB, and competing for the lock can make either fail.
|
|
||||||
func outsideScan(ctx context.Context, job string, run func(context.Context) error) {
|
|
||||||
release, ok := scanner.LockForMaintenance()
|
|
||||||
if !ok {
|
|
||||||
log.Debug(ctx, "Skipping "+job+" because a scan is in progress")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
if err := run(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error running "+job, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
|
|
||||||
// reports an artwork config change without acting on it.
|
|
||||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
|
||||||
return func() error {
|
|
||||||
schedulerInstance := scheduler.GetInstance()
|
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
|
||||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
|
||||||
}); err != nil {
|
|
||||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
|
||||||
outsideScan(ctx, "artwork prune", worker.RunPrune)
|
|
||||||
}); err != nil {
|
|
||||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also run the missing-row recheck once at startup so a never-scanned entity is picked up
|
|
||||||
// immediately, not only on the next hourly tick (e.g. after enabling the feature).
|
|
||||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
|
||||||
|
|
||||||
if err := worker.ReconcileConfig(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startPluginManager starts the plugin manager, if configured.
|
// startPluginManager starts the plugin manager, if configured.
|
||||||
func startPluginManager(ctx context.Context) func() error {
|
func startPluginManager(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
|
|
@ -485,7 +384,7 @@ func init() {
|
||||||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||||
|
|
||||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint")
|
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||||
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
|
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
|
||||||
|
|
||||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||||
|
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"path"
|
|
||||||
"runtime/pprof"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = pprof.NewProfile("nd-profiler-test")
|
|
||||||
|
|
||||||
var _ = Describe("profilerHandler", func() {
|
|
||||||
// Mirrors how server.MountRouter mounts the handler.
|
|
||||||
mount := func() http.Handler {
|
|
||||||
router := chi.NewRouter()
|
|
||||||
router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler())
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("serves a named profile",
|
|
||||||
func(basePath string) {
|
|
||||||
conf.Server.BasePath = basePath
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
|
|
||||||
mount().ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil))
|
|
||||||
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(w.Body.String()).To(HavePrefix("nd-profiler-test profile: total 0"))
|
|
||||||
},
|
|
||||||
Entry("without a BasePath", ""),
|
|
||||||
Entry("with a BasePath", "/music"),
|
|
||||||
Entry("with a root BasePath", "/"),
|
|
||||||
Entry("with a trailing-slash BasePath", "/music/"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("librariesWithChangedPID", func() {
|
|
||||||
var ds *tests.MockDataStore
|
|
||||||
var libs *tests.MockLibraryRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
libs = &tests.MockLibraryRepo{}
|
|
||||||
ds = &tests.MockDataStore{MockedLibrary: libs}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns only the libraries whose PID config changed", func() {
|
|
||||||
pid := model.Library{}.EffectivePID()
|
|
||||||
libs.SetData(model.Libraries{
|
|
||||||
{ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
|
||||||
{ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
|
||||||
{ID: 3, Name: "Never scanned"},
|
|
||||||
})
|
|
||||||
Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the error from the repository", func() {
|
|
||||||
libs.Err = errors.New("db down")
|
|
||||||
_, err := librariesWithChangedPID(GinkgoT().Context(), ds)
|
|
||||||
Expect(err).To(MatchError("db down"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
42
cmd/scan.go
42
cmd/scan.go
|
|
@ -4,13 +4,11 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -44,20 +42,15 @@ var scanCmd = &cobra.Command{
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) (bool, error) {
|
func trackScanInteractively(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||||
var changesDetected bool
|
|
||||||
var scanErrors []error
|
|
||||||
for status := range pl.ReadOrDone(ctx, progress) {
|
for status := range pl.ReadOrDone(ctx, progress) {
|
||||||
if status.Warning != "" {
|
if status.Warning != "" {
|
||||||
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
log.Warn(ctx, "Scan warning", "error", status.Warning)
|
||||||
}
|
}
|
||||||
if status.Error != "" {
|
if status.Error != "" {
|
||||||
log.Error(ctx, "Scan error", "error", status.Error)
|
log.Error(ctx, "Scan error", "error", status.Error)
|
||||||
scanErrors = append(scanErrors, errors.New(status.Error))
|
|
||||||
}
|
|
||||||
if status.ChangesDetected {
|
|
||||||
changesDetected = true
|
|
||||||
}
|
}
|
||||||
|
// Discard the progress status, we only care about errors
|
||||||
}
|
}
|
||||||
|
|
||||||
if fullScan {
|
if fullScan {
|
||||||
|
|
@ -65,7 +58,6 @@ func trackScanInteractively(ctx context.Context, progress <-chan *scanner.Progre
|
||||||
} else {
|
} else {
|
||||||
log.Info("Finished rescan")
|
log.Info("Finished rescan")
|
||||||
}
|
}
|
||||||
return changesDetected, errors.Join(scanErrors...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
func trackScanAsSubprocess(ctx context.Context, progress <-chan *scanner.ProgressInfo) {
|
||||||
|
|
@ -82,7 +74,7 @@ func runScanner(ctx context.Context) {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
defer db.Db().Close()
|
defer db.Db().Close()
|
||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
pls := core.NewPlaylists(ds)
|
||||||
|
|
||||||
// Parse targets from command line or file
|
// Parse targets from command line or file
|
||||||
var scanTargets []model.ScanTarget
|
var scanTargets []model.ScanTarget
|
||||||
|
|
@ -102,16 +94,6 @@ func runScanner(ctx context.Context) {
|
||||||
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
|
log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
|
||||||
}
|
}
|
||||||
|
|
||||||
effectiveFullScan := fullScan
|
|
||||||
if !subprocess {
|
|
||||||
effectiveFullScan = scanner.EffectiveFullScan(ctx, ds, fullScan, scanTargets)
|
|
||||||
if effectiveFullScan {
|
|
||||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
|
progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to scan", err)
|
log.Fatal(ctx, "Failed to scan", err)
|
||||||
|
|
@ -121,21 +103,7 @@ func runScanner(ctx context.Context) {
|
||||||
if subprocess {
|
if subprocess {
|
||||||
trackScanAsSubprocess(ctx, progress)
|
trackScanAsSubprocess(ctx, progress)
|
||||||
} else {
|
} else {
|
||||||
changesDetected, scanErr := trackScanInteractively(ctx, progress)
|
trackScanInteractively(ctx, progress)
|
||||||
runPostScanAnalysis(ctx, changesDetected, effectiveFullScan, scanErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runPostScanAnalysis(ctx context.Context, changesDetected, effectiveFullScan bool, scanErr error) {
|
|
||||||
if changesDetected {
|
|
||||||
if err := db.MarkOptimizePending(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error marking DB analysis pending", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if effectiveFullScan && scanErr == nil {
|
|
||||||
if err := db.Optimize(ctx); err != nil {
|
|
||||||
log.Error(ctx, "Error analyzing DB", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,14 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("trackScanInteractively", func() {
|
|
||||||
It("reports changes and scan errors", func() {
|
|
||||||
progress := make(chan *scanner.ProgressInfo, 2)
|
|
||||||
progress <- &scanner.ProgressInfo{ChangesDetected: true}
|
|
||||||
progress <- &scanner.ProgressInfo{Error: "scan failed"}
|
|
||||||
close(progress)
|
|
||||||
|
|
||||||
changesDetected, err := trackScanInteractively(context.Background(), progress)
|
|
||||||
Expect(changesDetected).To(BeTrue())
|
|
||||||
Expect(err).To(MatchError("scan failed"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("readTargetsFromFile", func() {
|
var _ = Describe("readTargetsFromFile", func() {
|
||||||
var tempDir string
|
var tempDir string
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var searchRebuildForce bool
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(searchRoot)
|
|
||||||
|
|
||||||
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
|
|
||||||
searchRoot.AddCommand(searchRebuildCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
searchRoot = &cobra.Command{
|
|
||||||
Use: "search",
|
|
||||||
Short: "Search index maintenance",
|
|
||||||
}
|
|
||||||
|
|
||||||
searchRebuildCmd = &cobra.Command{
|
|
||||||
Use: "rebuild",
|
|
||||||
Short: "Rebuild the full-text search index",
|
|
||||||
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
|
|
||||||
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
|
|
||||||
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
|
|
||||||
"library. This must be done offline",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runSearchRebuild(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func runSearchRebuild(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
|
|
||||||
log.Warn("Rebuild cancelled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Rebuilding the search index...")
|
|
||||||
err := db.RebuildFTS(ctx, db.Db())
|
|
||||||
db.Close(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error rebuilding the search index", err)
|
|
||||||
}
|
|
||||||
fmt.Println("Search index rebuilt successfully.")
|
|
||||||
}
|
|
||||||
43
cmd/svc.go
43
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
type svcControl struct {
|
type svcControl struct {
|
||||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
|
||||||
p.done = make(chan struct{})
|
p.done = make(chan struct{})
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
err := runNavidrome(p.ctx)
|
runNavidrome(p.ctx)
|
||||||
close(p.done)
|
close(p.done)
|
||||||
// service.Run() only returns when it gets a stop request, so exit here to let the
|
|
||||||
// service manager see the failure and restart the service
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +74,15 @@ func (p *svcControl) Stop(service.Service) error {
|
||||||
var svcInstance = sync.OnceValue(func() service.Service {
|
var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
options := make(service.KeyValue)
|
options := make(service.KeyValue)
|
||||||
options["Restart"] = "on-failure"
|
options["Restart"] = "on-failure"
|
||||||
options["SuccessExitStatus"] = "SIGKILL"
|
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||||
options["UserService"] = false
|
options["UserService"] = false
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder
|
||||||
options["SystemdScript"] = systemdScript
|
options["SystemdScript"] = systemdScript
|
||||||
if conf.Server.LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
options["LogOutput"] = false
|
options["LogOutput"] = false
|
||||||
} else {
|
} else {
|
||||||
options["LogOutput"] = true
|
options["LogOutput"] = true
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder
|
||||||
}
|
}
|
||||||
svcConfig := &service.Config{
|
svcConfig := &service.Config{
|
||||||
UserName: installUser,
|
UserName: installUser,
|
||||||
|
|
@ -136,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
|
||||||
println("Installing service with:")
|
println("Installing service with:")
|
||||||
println(" working directory: " + executablePath())
|
println(" working directory: " + executablePath())
|
||||||
println(" music folder: " + conf.Server.MusicFolder)
|
println(" music folder: " + conf.Server.MusicFolder)
|
||||||
println(" data folder: " + conf.Server.DataFolder.String())
|
println(" data folder: " + conf.Server.DataFolder)
|
||||||
if conf.Server.LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
println(" log file: " + conf.Server.LogFile)
|
println(" log file: " + conf.Server.LogFile)
|
||||||
} else {
|
} else {
|
||||||
println(" logs folder: " + conf.Server.DataFolder.String())
|
println(" logs folder: " + conf.Server.DataFolder)
|
||||||
}
|
}
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||||
|
|
@ -237,22 +232,22 @@ func buildExecuteCmd() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemdScript = `[Unit]
|
const systemdScript = `[Unit]
|
||||||
Description={{Description}}
|
Description={{.Description}}
|
||||||
ConditionFileIsExecutable={{Path | cmdEscape}}
|
ConditionFileIsExecutable={{.Path|cmdEscape}}
|
||||||
{{range Dependencies}}{{.}}
|
{{range $i, $dep := .Dependencies}}
|
||||||
{{end}}
|
{{$dep}} {{end}}
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
StartLimitInterval=5
|
StartLimitInterval=5
|
||||||
StartLimitBurst=10
|
StartLimitBurst=10
|
||||||
ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
|
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||||
{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}}
|
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||||
{{if UserName}}User={{UserName}}{{end}}
|
{{if .UserName}}User={{.UserName}}{{end}}
|
||||||
{{if Restart}}Restart={{Restart}}{{end}}
|
{{if .Restart}}Restart={{.Restart}}{{end}}
|
||||||
{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}}
|
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
|
||||||
TimeoutStopSec=20
|
TimeoutStopSec=20
|
||||||
RestartSec=120
|
RestartSec=120
|
||||||
EnvironmentFile=-/etc/sysconfig/{{Name}}
|
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||||
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
|
||||||
|
|
||||||
DevicePolicy=closed
|
DevicePolicy=closed
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|
@ -264,7 +259,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
RestrictNamespaces=yes
|
RestrictNamespaces=yes
|
||||||
RestrictRealtime=yes
|
RestrictRealtime=yes
|
||||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||||
{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}}
|
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("systemdScript template", func() {
|
|
||||||
systemdKeys := map[string]bool{
|
|
||||||
"Description": true, "Path": true, "Name": true, "Dependencies": true,
|
|
||||||
"Arguments": true, "ChRoot": true, "WorkingDirectory": true,
|
|
||||||
"UserName": true, "ReloadSignal": true, "PIDFile": true,
|
|
||||||
"LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true,
|
|
||||||
"Restart": true, "SuccessExitStatus": true, "EnvVars": true,
|
|
||||||
}
|
|
||||||
systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true}
|
|
||||||
|
|
||||||
actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`)
|
|
||||||
|
|
||||||
parseAction := func(action string) (key string, funcs []string) {
|
|
||||||
action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-"))
|
|
||||||
kw, rest, _ := strings.Cut(action, " ")
|
|
||||||
switch kw {
|
|
||||||
case "end", "else":
|
|
||||||
return "", nil
|
|
||||||
case "if", "range":
|
|
||||||
return strings.TrimSpace(rest), nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(action, "|")
|
|
||||||
for _, p := range parts[1:] {
|
|
||||||
funcs = append(funcs, strings.TrimSpace(p))
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[0]), funcs
|
|
||||||
}
|
|
||||||
|
|
||||||
It("only references keys and functions the service library provides", func() {
|
|
||||||
matches := actionRe.FindAllStringSubmatch(systemdScript, -1)
|
|
||||||
Expect(matches).ToNot(BeEmpty())
|
|
||||||
|
|
||||||
for _, m := range matches {
|
|
||||||
key, funcs := parseAction(m[1])
|
|
||||||
if key != "" && key != "." {
|
|
||||||
Expect(systemdKeys).To(HaveKey(key),
|
|
||||||
"template action %q uses a key unknown to kardianos/service", m[0])
|
|
||||||
}
|
|
||||||
for _, fn := range funcs {
|
|
||||||
Expect(systemdFuncs).To(HaveKey(fn),
|
|
||||||
"template action %q uses an unknown pipeline function", m[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
err := ds.WithTx(func(tx model.DataStore) error {
|
||||||
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
return fmt.Errorf("existing user '%s'", userID)
|
return fmt.Errorf("existing user '%s'", userID)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
||||||
return libraryError(user.Libraries)
|
return libraryError(user.Libraries)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().Put(ctx, &user)
|
err = tx.User(ctx).Put(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
var user *model.User
|
var user *model.User
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
err = ds.WithTx(func(tx model.DataStore) error {
|
||||||
count, err := tx.User().CountAll(ctx)
|
count, err := tx.User(ctx).CountAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.User().Delete(ctx, user.ID)
|
return tx.User(ctx).Delete(user.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if setAdmin && !user.IsAdmin {
|
if setAdmin && !user.IsAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx)
|
libraries, err := tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().Put(ctx, user)
|
err := tx.User(ctx).Put(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -393,11 +393,13 @@ func runUserList(ctx context.Context) {
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
userList, err := ds.User().ReadAll(ctx)
|
users, err := ds.User(ctx).ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userList := users.(model.Users)
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue