mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-14 05:17:13 +02:00
Compare commits
11 commits
master
...
feat/trans
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1f12d148d | ||
|
|
6fe2b76ac1 | ||
|
|
faa4acf583 | ||
|
|
c7865ff159 | ||
|
|
179ba1e600 | ||
|
|
803d96bf8c | ||
|
|
87e02a3c2d | ||
|
|
4d14ad812c | ||
|
|
b018d7b634 | ||
|
|
9219a8c2a4 | ||
|
|
58f7959204 |
1442 changed files with 29449 additions and 122752 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.2.0-1"
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
||||||
|
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
||||||
|
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
|
||||||
|
&& mv /usr/include/taglib/* /usr/include/ \
|
||||||
|
&& rmdir /usr/include/taglib \
|
||||||
|
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
||||||
|
|
||||||
|
ENV CGO_CFLAGS_ALLOW="--define-prefix"
|
||||||
|
|
||||||
# [Optional] Uncomment this line to install global node packages.
|
# [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.2.0-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});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
270
.github/workflows/pipeline.yml
vendored
270
.github/workflows/pipeline.yml
vendored
|
|
@ -14,6 +14,8 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
CROSS_TAGLIB_VERSION: "2.2.0-1"
|
||||||
|
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,sqlite_fts5 -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
|
||||||
|
|
@ -427,21 +232,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 }}
|
||||||
- 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@v7
|
||||||
|
|
@ -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
|
||||||
|
|
@ -493,7 +285,7 @@ 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@v8
|
||||||
|
|
@ -527,7 +319,7 @@ 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@v8
|
||||||
|
|
@ -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,7 +372,7 @@ 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@v8
|
||||||
with:
|
with:
|
||||||
|
|
@ -614,7 +406,7 @@ 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
|
||||||
|
|
@ -634,7 +426,7 @@ jobs:
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v7
|
||||||
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 }}
|
||||||
|
|
|
||||||
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"
|
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -37,14 +37,5 @@ AGENTS.md
|
||||||
*.wasm
|
*.wasm
|
||||||
*.ndp
|
*.ndp
|
||||||
openspec/
|
openspec/
|
||||||
.agents
|
|
||||||
go.work*
|
go.work*
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.playwright-mcp/
|
|
||||||
|
|
||||||
# Temp benchmark files
|
|
||||||
zz_*_test.go
|
|
||||||
|
|
||||||
# wazero compilation cache for the plugins test suite
|
|
||||||
/plugins/testdata/.wazero-cache/
|
|
||||||
/plugins/testdata/*.stage/
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,10 @@ linters:
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- containedctx
|
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
- errorlint
|
- errorlint
|
||||||
- forbidigo
|
|
||||||
- gocritic
|
- gocritic
|
||||||
- gocyclo
|
- gocyclo
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
|
|
@ -28,9 +26,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,14 +36,6 @@ 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
|
||||||
|
|
@ -58,12 +45,6 @@ linters:
|
||||||
- gosec
|
- gosec
|
||||||
path: _test\.go
|
path: _test\.go
|
||||||
text: "G703"
|
text: "G703"
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- containedctx
|
|
||||||
- path-except: 'db/migrations/'
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
|
|
@ -74,9 +55,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 +62,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}
|
|
||||||
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.2.0-1
|
||||||
|
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||||
|
|
||||||
|
# wget in busybox can't follow redirects
|
||||||
|
RUN <<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,sqlite_fts5 -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"]
|
||||||
|
|
||||||
|
|
|
||||||
73
Makefile
73
Makefile
|
|
@ -1,15 +1,14 @@
|
||||||
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)
|
||||||
|
GO_BUILD_TAGS=netgo,sqlite_fts5
|
||||||
comma:=,
|
|
||||||
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
|
|
||||||
|
|
||||||
# Set global environment variables, required for most targets
|
# 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 +19,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.2.1-1
|
||||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
GOLANGCI_LINT_VERSION ?= v2.11.1
|
||||||
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/*")
|
||||||
|
|
||||||
|
|
@ -79,8 +76,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 +93,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
|
||||||
|
|
@ -154,13 +112,12 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||||
.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
|
||||||
|
|
||||||
|
|
@ -220,6 +177,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 +189,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
|
||||||
|
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -35,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
||||||
|
|
||||||
## Cloud Hosting
|
## Cloud Hosting
|
||||||
|
|
||||||
Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
|
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
||||||
|
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||||
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||||
<br>
|
|
||||||
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
|
||||||
<br>
|
|
||||||
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[](https://www.pikapods.com/pods?run=navidrome)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -59,7 +52,6 @@ Several cloud hosting providers partner with us to offer [officially supported,
|
||||||
- **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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
167
cmd/root.go
167
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,7 @@ 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/taglib"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -44,9 +43,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 +75,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 +97,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 +125,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 +132,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 +141,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 +167,46 @@ 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
|
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
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"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
41
cmd/scan.go
41
cmd/scan.go
|
|
@ -4,12 +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/core/playlists"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
|
@ -44,20 +43,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 +59,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 +75,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 := playlists.NewPlaylists(ds, core.NewImageUploadService())
|
||||||
|
|
||||||
// Parse targets from command line or file
|
// Parse targets from command line or file
|
||||||
var scanTargets []model.ScanTarget
|
var scanTargets []model.ScanTarget
|
||||||
|
|
@ -102,16 +95,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 +104,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.")
|
|
||||||
}
|
|
||||||
42
cmd/svc.go
42
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,21 +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"
|
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
||||||
|
|
||||||
DevicePolicy=closed
|
DevicePolicy=closed
|
||||||
|
|
@ -264,7 +260,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{
|
||||||
|
|
|
||||||
36
cmd/utils.go
36
cmd/utils.go
|
|
@ -4,12 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
|
@ -18,33 +13,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
)
|
)
|
||||||
|
|
||||||
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
|
|
||||||
func existingDBFile() (string, bool) {
|
|
||||||
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return path, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireExistingDB aborts the command when the database file does not exist.
|
|
||||||
func requireExistingDB() {
|
|
||||||
if path, ok := existingDBFile(); !ok {
|
|
||||||
log.Fatal("No existing database", "path", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmYES(warning string) bool {
|
|
||||||
fmt.Println(warning)
|
|
||||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
|
||||||
var input string
|
|
||||||
_, err := fmt.Scanln(&input)
|
|
||||||
return input == "YES" && err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTabWriter keeps every CLI table on the same column settings.
|
|
||||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
|
||||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
|
|
@ -57,14 +25,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||||
user, err := ds.User().FindByUsername(ctx, id)
|
user, err := ds.User(ctx).FindByUsername(id)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
user, err = ds.User().Get(ctx, id)
|
user, err = ds.User(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
162
cmd/wire_gen.go
162
cmd/wire_gen.go
|
|
@ -17,13 +17,10 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/external"
|
"github.com/navidrome/navidrome/core/external"
|
||||||
"github.com/navidrome/navidrome/core/ffmpeg"
|
"github.com/navidrome/navidrome/core/ffmpeg"
|
||||||
"github.com/navidrome/navidrome/core/lyrics"
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/matcher"
|
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/core/quickconnect"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/sonic"
|
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -31,9 +28,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
"github.com/navidrome/navidrome/server/public"
|
"github.com/navidrome/navidrome/server/public"
|
||||||
"github.com/navidrome/navidrome/server/subsonic"
|
"github.com/navidrome/navidrome/server/subsonic"
|
||||||
|
|
@ -44,6 +39,7 @@ 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/taglib"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injectors from wire_injectors.go:
|
// Injectors from wire_injectors.go:
|
||||||
|
|
@ -67,22 +63,24 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
uploader := artwork.NewUploader(dataStore)
|
imageUploadService := core.NewImageUploadService()
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
insights := metrics.GetInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
|
fileCache := artwork.GetImageCache()
|
||||||
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||||
user := core.NewUser(dataStore, manager)
|
user := core.NewUser(dataStore, manager)
|
||||||
maintenance := core.NewMaintenance(dataStore)
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||||
matcherMatcher := matcher.New(dataStore)
|
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
|
||||||
quickConnect := quickconnect.GetInstance()
|
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,63 +88,27 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
share := core.NewShare(dataStore)
|
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
|
||||||
players := core.NewPlayers(dataStore)
|
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
matcherMatcher := matcher.New(dataStore)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
uploader := artwork.NewUploader(dataStore)
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
share := core.NewShare(dataStore)
|
||||||
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
|
players := core.NewPlayers(dataStore)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
imageUploadService := core.NewImageUploadService()
|
||||||
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
lyricsLyrics := lyrics.NewLyrics(manager)
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
fileCache := artwork.GetImageCache()
|
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
fFmpeg := ffmpeg.New()
|
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||||
players := core.NewPlayers(dataStore)
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider)
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
|
||||||
uploader := artwork.NewUploader(dataStore)
|
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
|
||||||
quickConnect := quickconnect.GetInstance()
|
|
||||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
router := apiv1.New(dataStore)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,15 +116,18 @@ func CreatePublicRouter() *public.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
fileCache := artwork.GetImageCache()
|
fileCache := artwork.GetImageCache()
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
fFmpeg := ffmpeg.New()
|
fFmpeg := ffmpeg.New()
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
broker := events.GetBroker()
|
||||||
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,13 +145,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
discovery := jellyfin.NewDiscovery(dataStore)
|
|
||||||
return discovery
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
|
@ -204,22 +162,36 @@ func CreatePrometheus() metrics.Metrics {
|
||||||
func CreateScanner(ctx context.Context) model.Scanner {
|
func CreateScanner(ctx context.Context) model.Scanner {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
fileCache := artwork.GetImageCache()
|
||||||
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
uploader := artwork.NewUploader(dataStore)
|
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
imageUploadService := core.NewImageUploadService()
|
||||||
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
return modelScanner
|
return modelScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
fileCache := artwork.GetImageCache()
|
||||||
|
fFmpeg := ffmpeg.New()
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
uploader := artwork.NewUploader(dataStore)
|
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
imageUploadService := core.NewImageUploadService()
|
||||||
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlistsPlaylists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
return watcher
|
return watcher
|
||||||
}
|
}
|
||||||
|
|
@ -231,32 +203,6 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||||
return playbackServer
|
return playbackServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkWorker() *artwork.Worker {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
fFmpeg := ffmpeg.New()
|
|
||||||
fileCache := artwork.GetImageCache()
|
|
||||||
worker := artwork.NewWorker(dataStore, imageStore, agentsAgents, fFmpeg, broker, fileCache)
|
|
||||||
return worker
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
fFmpeg := ffmpeg.New()
|
|
||||||
tracingResolver := artwork.NewTracingResolver(dataStore, agentsAgents, fFmpeg, trace, live)
|
|
||||||
return tracingResolver
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPluginManager() *plugins.Manager {
|
func getPluginManager() *plugins.Manager {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
|
@ -268,7 +214,7 @@ func getPluginManager() *plugins.Manager {
|
||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
|
|
|
||||||
|
|
@ -14,18 +14,14 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/lyrics"
|
"github.com/navidrome/navidrome/core/lyrics"
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/sonic"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
"github.com/navidrome/navidrome/server/public"
|
"github.com/navidrome/navidrome/server/public"
|
||||||
"github.com/navidrome/navidrome/server/subsonic"
|
"github.com/navidrome/navidrome/server/subsonic"
|
||||||
|
|
@ -36,31 +32,24 @@ var allProviders = wire.NewSet(
|
||||||
artwork.Set,
|
artwork.Set,
|
||||||
server.New,
|
server.New,
|
||||||
subsonic.New,
|
subsonic.New,
|
||||||
jellyfin.New,
|
|
||||||
jellyfin.NewDiscovery,
|
|
||||||
apiv1.New,
|
|
||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
public.New,
|
public.New,
|
||||||
persistence.New,
|
persistence.New,
|
||||||
lastfm.NewRouter,
|
lastfm.NewRouter,
|
||||||
listenbrainz.NewRouter,
|
listenbrainz.NewRouter,
|
||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
scanner.GetInstance,
|
scanner.New,
|
||||||
scanner.GetWatcher,
|
scanner.GetWatcher,
|
||||||
metrics.GetPrometheusInstance,
|
metrics.GetPrometheusInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
plugins.GetManager,
|
plugins.GetManager,
|
||||||
sonic.New,
|
|
||||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)),
|
|
||||||
wire.Bind(new(sonic.Engine), new(*sonic.Sonic)),
|
|
||||||
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)),
|
||||||
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)),
|
||||||
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)),
|
||||||
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
|
||||||
wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateDataStore() model.DataStore {
|
func CreateDataStore() model.DataStore {
|
||||||
|
|
@ -87,18 +76,6 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
@ -117,12 +94,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
@ -153,19 +124,6 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkWorker() *artwork.Worker {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
artwork.NewTracingResolver,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPluginManager() *plugins.Manager {
|
func getPluginManager() *plugins.Manager {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ package configtest
|
||||||
|
|
||||||
import "github.com/navidrome/navidrome/conf"
|
import "github.com/navidrome/navidrome/conf"
|
||||||
|
|
||||||
// TODO Remove this redirection and call SnapshotConfig directly from tests
|
|
||||||
func SetupConfig() func() {
|
func SetupConfig() func() {
|
||||||
return conf.SnapshotConfig()
|
oldValues := *conf.Server
|
||||||
|
return func() {
|
||||||
|
conf.Server = &oldValues
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,43 +2,33 @@ package conf
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
|
||||||
"regexp"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/bmatcuk/doublestar/v4"
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
"github.com/dustin/go-humanize"
|
|
||||||
"github.com/go-viper/encoding/ini"
|
"github.com/go-viper/encoding/ini"
|
||||||
"github.com/go-viper/mapstructure/v2"
|
|
||||||
"github.com/kr/pretty"
|
"github.com/kr/pretty"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/scheduler"
|
"github.com/navidrome/navidrome/scheduler"
|
||||||
"github.com/navidrome/navidrome/utils/run"
|
"github.com/navidrome/navidrome/utils/run"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configOptions struct {
|
type configOptions struct {
|
||||||
ConfigFile string `conf:"-"`
|
ConfigFile string
|
||||||
Address string
|
Address string
|
||||||
Port int
|
Port int
|
||||||
UnixSocketPerm string
|
UnixSocketPerm string
|
||||||
EnforceNonRootUser bool
|
|
||||||
MusicFolder string
|
MusicFolder string
|
||||||
DataFolder Dir
|
DataFolder string
|
||||||
CacheFolder Dir
|
CacheFolder string
|
||||||
DbPath string
|
DbPath string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
LogFile string
|
LogFile string
|
||||||
|
|
@ -53,11 +43,11 @@ type configOptions struct {
|
||||||
UIWelcomeMessage string
|
UIWelcomeMessage string
|
||||||
MaxSidebarPlaylists int
|
MaxSidebarPlaylists int
|
||||||
EnableTranscodingConfig bool
|
EnableTranscodingConfig bool
|
||||||
|
EnableTranscodingCancellation bool
|
||||||
EnableDownloads bool
|
EnableDownloads bool
|
||||||
EnableExternalServices bool
|
EnableExternalServices bool
|
||||||
EnableM3UExternalAlbumArt bool
|
EnableM3UExternalAlbumArt bool
|
||||||
EnableInsightsCollector bool
|
EnableInsightsCollector bool
|
||||||
EnableScheduledDBAnalyze bool
|
|
||||||
EnableMediaFileCoverArt bool
|
EnableMediaFileCoverArt bool
|
||||||
TranscodingCacheSize string
|
TranscodingCacheSize string
|
||||||
ImageCacheSize string
|
ImageCacheSize string
|
||||||
|
|
@ -68,12 +58,12 @@ type configOptions struct {
|
||||||
PlaylistsPath string
|
PlaylistsPath string
|
||||||
SmartPlaylistRefreshDelay time.Duration
|
SmartPlaylistRefreshDelay time.Duration
|
||||||
AutoTranscodeDownload bool
|
AutoTranscodeDownload bool
|
||||||
|
MaxConcurrentTranscodes int
|
||||||
DefaultDownsamplingFormat string
|
DefaultDownsamplingFormat string
|
||||||
Search searchOptions `json:",omitzero"`
|
Search searchOptions `json:",omitzero"`
|
||||||
Matcher matcherOptions `json:",omitzero"`
|
SimilarSongsMatchThreshold int
|
||||||
RecentlyAddedByModTime bool
|
RecentlyAddedByModTime bool
|
||||||
PreferSortTags bool
|
PreferSortTags bool
|
||||||
EnableNaturalSorting bool
|
|
||||||
IgnoredArticles string
|
IgnoredArticles string
|
||||||
IndexGroups string
|
IndexGroups string
|
||||||
FFmpegPath string
|
FFmpegPath string
|
||||||
|
|
@ -81,7 +71,6 @@ type configOptions struct {
|
||||||
MPVCmdTemplate string
|
MPVCmdTemplate string
|
||||||
CoverArtPriority string
|
CoverArtPriority string
|
||||||
CoverArtQuality int
|
CoverArtQuality int
|
||||||
EnableWebPEncoding bool
|
|
||||||
ArtistArtPriority string
|
ArtistArtPriority string
|
||||||
ArtistImageFolder string
|
ArtistImageFolder string
|
||||||
DiscArtPriority string
|
DiscArtPriority string
|
||||||
|
|
@ -90,9 +79,7 @@ type configOptions struct {
|
||||||
EnableFavourites bool
|
EnableFavourites bool
|
||||||
EnableStarRating bool
|
EnableStarRating bool
|
||||||
EnableUserEditing bool
|
EnableUserEditing bool
|
||||||
EnableArtworkUpload bool
|
EnableCoverArtUpload bool
|
||||||
MaxImageUploadSize string
|
|
||||||
MaxImageSize string
|
|
||||||
EnableSharing bool
|
EnableSharing bool
|
||||||
ShareURL string
|
ShareURL string
|
||||||
DefaultShareExpiration time.Duration
|
DefaultShareExpiration time.Duration
|
||||||
|
|
@ -101,11 +88,9 @@ type configOptions struct {
|
||||||
DefaultLanguage string
|
DefaultLanguage string
|
||||||
DefaultUIVolume int
|
DefaultUIVolume int
|
||||||
UISearchDebounceMs int
|
UISearchDebounceMs int
|
||||||
UICoverArtSize int
|
|
||||||
EnableReplayGain bool
|
EnableReplayGain bool
|
||||||
EnableCoverAnimation bool
|
EnableCoverAnimation bool
|
||||||
EnableNowPlaying bool
|
EnableNowPlaying bool
|
||||||
UIPlaybackReportInterval time.Duration
|
|
||||||
GATrackingID string
|
GATrackingID string
|
||||||
EnableLogRedacting bool
|
EnableLogRedacting bool
|
||||||
AuthRequestLimit int
|
AuthRequestLimit int
|
||||||
|
|
@ -121,11 +106,9 @@ type configOptions struct {
|
||||||
PID pidOptions `json:",omitzero"`
|
PID pidOptions `json:",omitzero"`
|
||||||
Inspect inspectOptions `json:",omitzero"`
|
Inspect inspectOptions `json:",omitzero"`
|
||||||
Subsonic subsonicOptions `json:",omitzero"`
|
Subsonic subsonicOptions `json:",omitzero"`
|
||||||
Transcoding transcodingOptions `json:",omitzero"`
|
|
||||||
LastFM lastfmOptions `json:",omitzero"`
|
LastFM lastfmOptions `json:",omitzero"`
|
||||||
Deezer deezerOptions `json:",omitzero"`
|
Deezer deezerOptions `json:",omitzero"`
|
||||||
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
ListenBrainz listenBrainzOptions `json:",omitzero"`
|
||||||
Jellyfin jellyfinOptions `json:",omitzero"`
|
|
||||||
EnableScrobbleHistory bool
|
EnableScrobbleHistory bool
|
||||||
Tags map[string]TagConf `json:",omitempty"`
|
Tags map[string]TagConf `json:",omitempty"`
|
||||||
Agents string
|
Agents string
|
||||||
|
|
@ -146,9 +129,8 @@ type configOptions struct {
|
||||||
DevArtworkMaxRequests int
|
DevArtworkMaxRequests int
|
||||||
DevArtworkThrottleBacklogLimit int
|
DevArtworkThrottleBacklogLimit int
|
||||||
DevArtworkThrottleBacklogTimeout time.Duration
|
DevArtworkThrottleBacklogTimeout time.Duration
|
||||||
DevArtworkThrottleBuffered bool
|
DevTranscodeThrottleBacklogLimit int
|
||||||
DevArtworkWorkerConcurrency int
|
DevTranscodeThrottleBacklogTimeout time.Duration
|
||||||
DevArtworkExternalMaxRPS int
|
|
||||||
DevArtistInfoTimeToLive time.Duration
|
DevArtistInfoTimeToLive time.Duration
|
||||||
DevAlbumInfoTimeToLive time.Duration
|
DevAlbumInfoTimeToLive time.Duration
|
||||||
DevExternalScanner bool
|
DevExternalScanner bool
|
||||||
|
|
@ -159,9 +141,10 @@ type configOptions struct {
|
||||||
DevEnablePluginsInsights bool
|
DevEnablePluginsInsights bool
|
||||||
DevPluginCompilationTimeout time.Duration
|
DevPluginCompilationTimeout time.Duration
|
||||||
DevExternalArtistFetchMultiplier float64
|
DevExternalArtistFetchMultiplier float64
|
||||||
|
DevOptimizeDB bool
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
DevEnableMediaFileProbe bool
|
DevEnableMediaFileProbe bool
|
||||||
DevAPIv1 bool
|
DevJpegCoverArt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
|
|
@ -171,20 +154,12 @@ type scannerOptions struct {
|
||||||
ScanOnStartup bool
|
ScanOnStartup bool
|
||||||
Extractor string
|
Extractor string
|
||||||
ArtistJoiner string
|
ArtistJoiner string
|
||||||
ArtistSplitExceptions []string // Artist names never split by tag separators
|
|
||||||
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
GenreSeparators string // Deprecated: Use Tags.genre.Split instead
|
||||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||||
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
|
|
||||||
PurgeMissing string // Values: "never", "always", "full"
|
PurgeMissing string // Values: "never", "always", "full"
|
||||||
}
|
}
|
||||||
|
|
||||||
type transcodingOptions struct {
|
|
||||||
MaxConcurrent int
|
|
||||||
MaxConcurrentPerUser int
|
|
||||||
EnableCancellation bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type subsonicOptions struct {
|
type subsonicOptions struct {
|
||||||
AppendSubtitle bool
|
AppendSubtitle bool
|
||||||
AppendAlbumVersion bool
|
AppendAlbumVersion bool
|
||||||
|
|
@ -212,7 +187,7 @@ type lastfmOptions struct {
|
||||||
ScrobbleFirstArtistOnly bool
|
ScrobbleFirstArtistOnly bool
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
Languages []string `conf:"-"` // Computed from Language, split by comma
|
Languages []string // Computed from Language, split by comma
|
||||||
}
|
}
|
||||||
|
|
||||||
type deezerOptions struct {
|
type deezerOptions struct {
|
||||||
|
|
@ -220,7 +195,7 @@ type deezerOptions struct {
|
||||||
Language string
|
Language string
|
||||||
|
|
||||||
// Computed values
|
// Computed values
|
||||||
Languages []string `conf:"-"` // Computed from Language, split by comma
|
Languages []string // Computed from Language, split by comma
|
||||||
}
|
}
|
||||||
|
|
||||||
type listenBrainzOptions struct {
|
type listenBrainzOptions struct {
|
||||||
|
|
@ -230,20 +205,6 @@ type listenBrainzOptions struct {
|
||||||
TrackAlgorithm string
|
TrackAlgorithm string
|
||||||
}
|
}
|
||||||
|
|
||||||
type jellyfinOptions struct {
|
|
||||||
Enabled bool
|
|
||||||
ServerName string
|
|
||||||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
|
||||||
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
|
||||||
ExposedPublicUsers string
|
|
||||||
AutoDiscovery bool
|
|
||||||
QuickConnect bool
|
|
||||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
|
||||||
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
|
||||||
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
|
||||||
MaxConcurrentStreams int
|
|
||||||
}
|
|
||||||
|
|
||||||
type httpHeaderOptions struct {
|
type httpHeaderOptions struct {
|
||||||
FrameOptions string
|
FrameOptions string
|
||||||
}
|
}
|
||||||
|
|
@ -265,7 +226,7 @@ type jukeboxOptions struct {
|
||||||
|
|
||||||
type backupOptions struct {
|
type backupOptions struct {
|
||||||
Count int
|
Count int
|
||||||
Path Dir
|
Path string
|
||||||
Schedule string
|
Schedule string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,7 +244,7 @@ type inspectOptions struct {
|
||||||
|
|
||||||
type pluginsOptions struct {
|
type pluginsOptions struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
Folder Dir
|
Folder string
|
||||||
CacheSize string
|
CacheSize string
|
||||||
AutoReload bool
|
AutoReload bool
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
|
@ -300,119 +261,87 @@ type searchOptions struct {
|
||||||
FullString bool
|
FullString bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type matcherOptions struct {
|
|
||||||
PreferStarred bool
|
|
||||||
FuzzyThreshold int
|
|
||||||
}
|
|
||||||
|
|
||||||
// logFatal prints a fatal error message to stderr and exits.
|
|
||||||
// Overridden in tests to allow testing fatal paths.
|
|
||||||
var logFatal = func(args ...any) {
|
|
||||||
_, _ = fmt.Fprintln(os.Stderr, append([]any{"FATAL:"}, args...)...)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var getEUID = os.Geteuid
|
|
||||||
|
|
||||||
var currentGOOS = func() string {
|
|
||||||
return runtime.GOOS
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLSEnabled reports whether the server serves HTTPS. Both halves are required,
|
|
||||||
// so callers cannot infer it from the certificate alone.
|
|
||||||
func (c *configOptions) TLSEnabled() bool {
|
|
||||||
return c.TLSCert != "" && c.TLSKey != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
Server = &configOptions{}
|
Server = &configOptions{}
|
||||||
hooks []func()
|
hooks []func()
|
||||||
)
|
)
|
||||||
|
|
||||||
// SnapshotConfig returns a function that restores Server to its current state.
|
|
||||||
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
|
|
||||||
func SnapshotConfig() func() {
|
|
||||||
snapshot, err := json.Marshal(Server)
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
|
|
||||||
}
|
|
||||||
return func() {
|
|
||||||
var restored configOptions
|
|
||||||
if err := json.Unmarshal(snapshot, &restored); err != nil {
|
|
||||||
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
|
|
||||||
}
|
|
||||||
Server = &restored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func LoadFromFile(confFile string) {
|
func LoadFromFile(confFile string) {
|
||||||
viper.SetConfigFile(confFile)
|
viper.SetConfigFile(confFile)
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Error reading config file:", err)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error reading config file:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
Load(true)
|
Load(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) {
|
|
||||||
if val.Nanoseconds() < 0 {
|
|
||||||
log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original)
|
|
||||||
*val = original
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Load(noConfigDump bool) {
|
func Load(noConfigDump bool) {
|
||||||
parseIniFileConfiguration()
|
parseIniFileConfiguration()
|
||||||
remapEnvVarKeysFromConfig()
|
|
||||||
|
|
||||||
// Map deprecated options to their new names for backwards compatibility
|
// Map deprecated options to their new names for backwards compatibility
|
||||||
for _, o := range deprecatedOptions {
|
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||||
if o.replacement != "" {
|
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||||
mapDeprecatedOption(o.name, o.replacement)
|
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||||
}
|
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||||
}
|
|
||||||
|
|
||||||
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
err := viper.Unmarshal(&Server)
|
||||||
mapstructure.ComposeDecodeHookFunc(
|
|
||||||
mapstructure.TextUnmarshallerHookFunc(),
|
|
||||||
mapstructure.StringToTimeDurationHookFunc(),
|
|
||||||
mapstructure.StringToSliceHookFunc(","),
|
|
||||||
),
|
|
||||||
))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Error parsing config:", err)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate non-root user early, before any filesystem operations
|
err = os.MkdirAll(Server.DataFolder, os.ModePerm)
|
||||||
if err := validateEnforceNonRootUser(); err != nil {
|
if err != nil {
|
||||||
logFatal(err)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating data path:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.CacheFolder.String() == "" {
|
if Server.CacheFolder == "" {
|
||||||
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
|
Server.CacheFolder = filepath.Join(Server.DataFolder, "cache")
|
||||||
|
}
|
||||||
|
err = os.MkdirAll(Server.CacheFolder, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating cache path:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating artwork path:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Server.Plugins.Enabled {
|
if Server.Plugins.Enabled {
|
||||||
if Server.Plugins.Folder.String() == "" {
|
if Server.Plugins.Folder == "" {
|
||||||
Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
|
Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins")
|
||||||
} else {
|
}
|
||||||
Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
|
err = os.MkdirAll(Server.Plugins.Folder, 0700)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating plugins path:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||||
if Server.DbPath == "" {
|
if Server.DbPath == "" {
|
||||||
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
|
Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if Server.Backup.Path != "" {
|
||||||
|
err = os.MkdirAll(Server.Backup.Path, os.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error creating backup path:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
out := os.Stderr
|
out := os.Stderr
|
||||||
if Server.LogFile != "" {
|
if Server.LogFile != "" {
|
||||||
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
|
||||||
}
|
|
||||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
_, _ = fmt.Fprintf(os.Stderr, "FATAL: Error opening log file %s: %s\n", Server.LogFile, err.Error())
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
log.SetOutput(out)
|
log.SetOutput(out)
|
||||||
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
||||||
|
|
@ -428,38 +357,15 @@ func Load(noConfigDump bool) {
|
||||||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||||
log.SetRedacting(Server.EnableLogRedacting)
|
log.SetRedacting(Server.EnableLogRedacting)
|
||||||
|
|
||||||
durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout)
|
|
||||||
durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh)
|
|
||||||
durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration)
|
|
||||||
durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval)
|
|
||||||
durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength)
|
|
||||||
durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait)
|
|
||||||
|
|
||||||
durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate)
|
|
||||||
durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout)
|
|
||||||
durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive)
|
|
||||||
durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive)
|
|
||||||
durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay)
|
|
||||||
durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout)
|
|
||||||
|
|
||||||
// Log deprecated, removed and unknown options
|
|
||||||
for _, o := range deprecatedOptions {
|
|
||||||
logDeprecatedOptions(o.name, o.replacement)
|
|
||||||
}
|
|
||||||
logRemovedOptions(removedOptions...)
|
|
||||||
logUnknownOptions()
|
|
||||||
|
|
||||||
err = run.Sequentially(
|
err = run.Sequentially(
|
||||||
validateScanSchedule,
|
validateScanSchedule,
|
||||||
validateBackupSchedule,
|
validateBackupSchedule,
|
||||||
validatePlaylistsPath,
|
validatePlaylistsPath,
|
||||||
validatePurgeMissingOption,
|
validatePurgeMissingOption,
|
||||||
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
|
|
||||||
validateByteSize("MaxImageSize", Server.MaxImageSize),
|
|
||||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal(err)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
||||||
|
|
@ -467,7 +373,8 @@ func Load(noConfigDump bool) {
|
||||||
if Server.BaseURL != "" {
|
if Server.BaseURL != "" {
|
||||||
u, err := url.Parse(Server.BaseURL)
|
u, err := url.Parse(Server.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Invalid BaseURL:", err)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Invalid BaseURL:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
Server.BasePath = u.Path
|
Server.BasePath = u.Path
|
||||||
u.Path = ""
|
u.Path = ""
|
||||||
|
|
@ -508,25 +415,18 @@ func Load(noConfigDump bool) {
|
||||||
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||||
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
||||||
|
|
||||||
// Validate other options
|
// Deprecated options
|
||||||
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
|
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||||
newValue := max(200, min(1200, Server.UICoverArtSize))
|
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||||
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
|
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||||
Server.UICoverArtSize = newValue
|
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
||||||
}
|
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||||
|
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||||
|
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||||
|
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||||
|
|
||||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
// Removed options
|
||||||
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
|
||||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
|
||||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
|
||||||
if imgSize < uploadSize {
|
|
||||||
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
|
|
||||||
Server.MaxImageSize = Server.MaxImageUploadSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call init hooks
|
// Call init hooks
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
|
|
@ -534,26 +434,9 @@ func Load(noConfigDump bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deprecatedOptions still work, but will be removed in a future release. An empty
|
|
||||||
// replacement means the option is now ignored.
|
|
||||||
var deprecatedOptions = []struct{ name, replacement string }{
|
|
||||||
{"Scanner.GenreSeparators", ""},
|
|
||||||
{"Scanner.GroupAlbumReleases", ""},
|
|
||||||
{"DevEnableBufferedScrobble", ""},
|
|
||||||
{"SearchFullString", "Search.FullString"},
|
|
||||||
{"ReverseProxyWhitelist", "ExtAuth.TrustedSources"},
|
|
||||||
{"ReverseProxyUserHeader", "ExtAuth.UserHeader"},
|
|
||||||
{"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"},
|
|
||||||
{"CoverJpegQuality", "CoverArtQuality"},
|
|
||||||
{"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"},
|
|
||||||
{"EnableTranscodingCancellation", "Transcoding.EnableCancellation"},
|
|
||||||
}
|
|
||||||
|
|
||||||
var removedOptions = []string{"Spotify.ID", "Spotify.Secret"}
|
|
||||||
|
|
||||||
func logDeprecatedOptions(oldName, newName string) {
|
func logDeprecatedOptions(oldName, newName string) {
|
||||||
envVar := envVarName(oldName)
|
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
|
||||||
newEnvVar := envVarName(newName)
|
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
|
||||||
logWarning := func(oldName, newName string) {
|
logWarning := func(oldName, newName string) {
|
||||||
if newName != "" {
|
if newName != "" {
|
||||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
|
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
|
||||||
|
|
@ -573,7 +456,7 @@ func logDeprecatedOptions(oldName, newName string) {
|
||||||
// not available anymore
|
// not available anymore
|
||||||
func logRemovedOptions(options ...string) {
|
func logRemovedOptions(options ...string) {
|
||||||
for _, option := range options {
|
for _, option := range options {
|
||||||
envVar := envVarName(option)
|
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||||
logWarning := func(option string) {
|
logWarning := func(option string) {
|
||||||
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
||||||
}
|
}
|
||||||
|
|
@ -586,201 +469,14 @@ func logRemovedOptions(options ...string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// remapEnvVarKeysFromConfig detects ND_-prefixed keys in the config file (users mistakenly
|
|
||||||
// using environment variable names) and remaps them to canonical Viper keys with a warning.
|
|
||||||
func remapEnvVarKeysFromConfig() {
|
|
||||||
for _, key := range viper.AllKeys() {
|
|
||||||
if !strings.HasPrefix(key, "nd_") || !viper.InConfig(key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
stripped := strings.TrimPrefix(key, "nd_")
|
|
||||||
canonicalKey := ndKeyToCanonical(key)
|
|
||||||
displayNDKey := "ND_" + strings.ToUpper(stripped)
|
|
||||||
canonicalName := canonicalOptionName(canonicalKey)
|
|
||||||
|
|
||||||
if viper.InConfig(canonicalKey) {
|
|
||||||
logFatal(fmt.Sprintf(
|
|
||||||
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
|
|
||||||
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
|
|
||||||
displayNDKey, cmp.Or(canonicalName, toPascalCase(canonicalKey)),
|
|
||||||
))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
viper.Set(canonicalKey, viper.Get(key))
|
|
||||||
// Unknown keys get no advice here, logUnknownOptions reports them instead
|
|
||||||
if canonicalName != "" {
|
|
||||||
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
|
|
||||||
"The 'ND_' prefix is only needed for environment variables.\n",
|
|
||||||
displayNDKey, canonicalName,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
||||||
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
||||||
func mapDeprecatedOption(legacyName, newName string) {
|
func mapDeprecatedOption(legacyName, newName string) {
|
||||||
// viper.Set outranks the config file, so an explicit replacement must win over the legacy value
|
if viper.IsSet(legacyName) {
|
||||||
if viper.IsSet(legacyName) && !explicitlySet(newName) {
|
|
||||||
viper.Set(newName, viper.Get(legacyName))
|
viper.Set(newName, viper.Get(legacyName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// explicitlySet reports whether the user provided the option, ignoring defaults,
|
|
||||||
// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config
|
|
||||||
// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach.
|
|
||||||
func explicitlySet(name string) bool {
|
|
||||||
envVar := envVarName(name)
|
|
||||||
return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar))
|
|
||||||
}
|
|
||||||
|
|
||||||
func envVarName(option string) string {
|
|
||||||
if option == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func logUnknownOptions() {
|
|
||||||
for _, key := range unknownConfigKeys() {
|
|
||||||
msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key)
|
|
||||||
if matches := suggestOptions(key); len(matches) > 0 {
|
|
||||||
msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '"))
|
|
||||||
}
|
|
||||||
log.Warn(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// suggestOptions returns the known options sharing the last segment with key,
|
|
||||||
// catching options written outside their section.
|
|
||||||
func suggestOptions(key string) []string {
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
leaf := leafKey(key)
|
|
||||||
canonical, _ := configKeys()
|
|
||||||
var matches []string
|
|
||||||
for known, name := range canonical {
|
|
||||||
// Removed options are known only so they get their own warning, never suggest them
|
|
||||||
if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) {
|
|
||||||
matches = append(matches, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.Sort(matches)
|
|
||||||
return matches
|
|
||||||
}
|
|
||||||
|
|
||||||
func leafKey(key string) string {
|
|
||||||
return key[strings.LastIndex(key, ".")+1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// unknownConfigKeys returns config file keys that don't match any known option, so
|
|
||||||
// typos and options written outside their section don't fail silently.
|
|
||||||
func unknownConfigKeys() []string {
|
|
||||||
// INI files keep the original [default] section alongside the merged one
|
|
||||||
skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini")
|
|
||||||
|
|
||||||
var unknown []string
|
|
||||||
for _, key := range viper.AllKeys() {
|
|
||||||
if !viper.InConfig(key) || canonicalOptionName(key) != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if skipDefault && strings.HasPrefix(key, "default.") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid
|
|
||||||
if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
unknown = append(unknown, key)
|
|
||||||
}
|
|
||||||
slices.Sort(unknown)
|
|
||||||
return asWrittenInConfigFile(unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ndKeyToCanonical(key string) string {
|
|
||||||
return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
// canonicalOptionName returns the documented spelling of a known option key, or ""
|
|
||||||
// if it matches no option. Subkeys of free-form maps have no fixed spelling.
|
|
||||||
func canonicalOptionName(key string) string {
|
|
||||||
keys, prefixes := configKeys()
|
|
||||||
if name, ok := keys[key]; ok {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) {
|
|
||||||
return toPascalCase(key)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// asWrittenInConfigFile restores the casing the keys have in the config file, as
|
|
||||||
// viper lowercases every key it loads.
|
|
||||||
func asWrittenInConfigFile(keys []string) []string {
|
|
||||||
if len(keys) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(viper.ConfigFileUsed())
|
|
||||||
if err != nil {
|
|
||||||
return keys
|
|
||||||
}
|
|
||||||
casing := map[string]string{}
|
|
||||||
for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) {
|
|
||||||
for segment := range strings.SplitSeq(match[1], ".") {
|
|
||||||
lower := strings.ToLower(segment)
|
|
||||||
casing[lower] = cmp.Or(casing[lower], segment)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return slice.Map(keys, func(key string) string {
|
|
||||||
segments := strings.Split(key, ".")
|
|
||||||
for i, s := range segments {
|
|
||||||
segments[i] = cmp.Or(casing[s], s)
|
|
||||||
}
|
|
||||||
return strings.Join(segments, ".")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Matches keys and section headers in all supported config formats.
|
|
||||||
var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`)
|
|
||||||
|
|
||||||
// configKeys maps every accepted option name, lowercased, to its canonical spelling,
|
|
||||||
// plus the prefixes of free-form map options (Tags, DevLogLevels).
|
|
||||||
var configKeys = sync.OnceValues(func() (map[string]string, []string) {
|
|
||||||
keys := map[string]string{}
|
|
||||||
var prefixes []string
|
|
||||||
|
|
||||||
var collect func(t reflect.Type, prefix string)
|
|
||||||
collect = func(t reflect.Type, prefix string) {
|
|
||||||
for field := range t.Fields() {
|
|
||||||
// `conf:"-"` marks values computed during Load, not settable in the config
|
|
||||||
if !field.IsExported() || field.Tag.Get("conf") == "-" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := prefix + field.Name
|
|
||||||
if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) {
|
|
||||||
collect(field.Type, name+".")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lower := strings.ToLower(name)
|
|
||||||
keys[lower] = name
|
|
||||||
if field.Type.Kind() == reflect.Map {
|
|
||||||
prefixes = append(prefixes, lower+".")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
collect(reflect.TypeFor[configOptions](), "")
|
|
||||||
|
|
||||||
for _, o := range deprecatedOptions {
|
|
||||||
keys[strings.ToLower(o.name)] = o.name
|
|
||||||
}
|
|
||||||
for _, o := range removedOptions {
|
|
||||||
keys[strings.ToLower(o)] = o
|
|
||||||
}
|
|
||||||
return keys, prefixes
|
|
||||||
})
|
|
||||||
|
|
||||||
var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
|
||||||
|
|
||||||
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
||||||
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
||||||
// section into the root level.
|
// section into the root level.
|
||||||
|
|
@ -790,15 +486,18 @@ func parseIniFileConfiguration() {
|
||||||
var iniConfig map[string]any
|
var iniConfig map[string]any
|
||||||
err := viper.Unmarshal(&iniConfig)
|
err := viper.Unmarshal(&iniConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Error parsing config:", err)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
cfg, ok := iniConfig["default"].(map[string]any)
|
cfg, ok := iniConfig["default"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
logFatal("Error parsing config: missing [default] section:", iniConfig)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config: missing [default] section:", iniConfig)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
err = viper.MergeConfigMap(cfg)
|
err = viper.MergeConfigMap(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Error parsing config:", err)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -820,7 +519,8 @@ func validatePlaylistsPath() error {
|
||||||
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||||
_, err := doublestar.Match(path, "")
|
_, err := doublestar.Match(path, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
|
log.Error("Invalid PlaylistsPath", "path", path, err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -847,40 +547,13 @@ func validatePurgeMissingOption() error {
|
||||||
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
||||||
if !valid {
|
if !valid {
|
||||||
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||||
|
log.Error(err.Error())
|
||||||
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateByteSize(name, value string) func() error {
|
|
||||||
return func() error {
|
|
||||||
size, err := humanize.ParseBytes(value)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err)
|
|
||||||
}
|
|
||||||
if size == 0 {
|
|
||||||
return fmt.Errorf("invalid %s %q: must be greater than zero", name, value)
|
|
||||||
}
|
|
||||||
if size > math.MaxInt64 {
|
|
||||||
return fmt.Errorf("invalid %s %q: value is too large", name, value)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateEnforceNonRootUser() error {
|
|
||||||
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if getEUID() == 0 {
|
|
||||||
return fmt.Errorf("EnforceNonRootUser is enabled but Navidrome is running as root")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateScanSchedule() error {
|
func validateScanSchedule() error {
|
||||||
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||||
Server.Scanner.Schedule = ""
|
Server.Scanner.Schedule = ""
|
||||||
|
|
@ -892,7 +565,7 @@ func validateScanSchedule() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateBackupSchedule() error {
|
func validateBackupSchedule() error {
|
||||||
if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
||||||
Server.Backup.Schedule = ""
|
Server.Backup.Schedule = ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -904,9 +577,9 @@ func validateBackupSchedule() error {
|
||||||
func validateSchedule(schedule, field string) (string, error) {
|
func validateSchedule(schedule, field string) (string, error) {
|
||||||
_, err := scheduler.ParseCrontab(schedule)
|
_, err := scheduler.ParseCrontab(schedule)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return schedule, fmt.Errorf("invalid %s %q (see https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format): %w", field, schedule, err)
|
log.Error(fmt.Sprintf("Invalid %s. Please read format spec at https://pkg.go.dev/github.com/robfig/cron#hdr-CRON_Expression_Format", field), "schedule", schedule, err)
|
||||||
}
|
}
|
||||||
return schedule, nil
|
return schedule, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateURL checks if the provided URL is valid and has either http or https scheme.
|
// validateURL checks if the provided URL is valid and has either http or https scheme.
|
||||||
|
|
@ -918,13 +591,19 @@ func validateURL(optionName, optionURL string) func() error {
|
||||||
}
|
}
|
||||||
u, err := url.Parse(optionURL)
|
u, err := url.Parse(optionURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid %s %q: %w", optionName, optionURL, err)
|
log.Error(fmt.Sprintf("Invalid %s: it could not be parsed", optionName), "url", optionURL, "err", err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
if u.Scheme != "http" && u.Scheme != "https" {
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
return fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
err := fmt.Errorf("invalid scheme for %s: '%s'. Only 'http' and 'https' are allowed", optionName, u.Scheme)
|
||||||
|
log.Error(err.Error())
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
// Require an absolute URL with a non-empty host and no opaque component.
|
||||||
if u.Host == "" || u.Opaque != "" {
|
if u.Host == "" || u.Opaque != "" {
|
||||||
return fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
err := fmt.Errorf("invalid %s: '%s'. A full http(s) URL with a non-empty host is required", optionName, optionURL)
|
||||||
|
log.Error(err.Error())
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -941,21 +620,6 @@ func normalizeSearchBackend(value string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// toPascalCase converts a dotted lowercase config key to PascalCase for display.
|
|
||||||
// Example: "scanner.schedule" → "Scanner.Schedule"
|
|
||||||
func toPascalCase(key string) string {
|
|
||||||
if key == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parts := strings.Split(key, ".")
|
|
||||||
for i, part := range parts {
|
|
||||||
if len(part) > 0 {
|
|
||||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
// AddHook is used to register initialization code that should run as soon as the config is loaded
|
||||||
func AddHook(hook func()) {
|
func AddHook(hook func()) {
|
||||||
hooks = append(hooks, hook)
|
hooks = append(hooks, hook)
|
||||||
|
|
@ -980,7 +644,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("address", "0.0.0.0")
|
viper.SetDefault("address", "0.0.0.0")
|
||||||
viper.SetDefault("port", 4533)
|
viper.SetDefault("port", 4533)
|
||||||
viper.SetDefault("unixsocketperm", "0660")
|
viper.SetDefault("unixsocketperm", "0660")
|
||||||
viper.SetDefault("enforcenonrootuser", false)
|
|
||||||
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
viper.SetDefault("sessiontimeout", consts.DefaultSessionTimeout)
|
||||||
viper.SetDefault("baseurl", "")
|
viper.SetDefault("baseurl", "")
|
||||||
viper.SetDefault("tlscert", "")
|
viper.SetDefault("tlscert", "")
|
||||||
|
|
@ -989,6 +652,7 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("uiwelcomemessage", "")
|
viper.SetDefault("uiwelcomemessage", "")
|
||||||
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists)
|
||||||
viper.SetDefault("enabletranscodingconfig", false)
|
viper.SetDefault("enabletranscodingconfig", false)
|
||||||
|
viper.SetDefault("enabletranscodingcancellation", false)
|
||||||
viper.SetDefault("transcodingcachesize", "100MB")
|
viper.SetDefault("transcodingcachesize", "100MB")
|
||||||
viper.SetDefault("imagecachesize", "100MB")
|
viper.SetDefault("imagecachesize", "100MB")
|
||||||
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute)
|
||||||
|
|
@ -996,32 +660,28 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("autoimportplaylists", true)
|
viper.SetDefault("autoimportplaylists", true)
|
||||||
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
||||||
viper.SetDefault("playlistspath", "")
|
viper.SetDefault("playlistspath", "")
|
||||||
viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh)
|
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||||
viper.SetDefault("enabledownloads", true)
|
viper.SetDefault("enabledownloads", true)
|
||||||
viper.SetDefault("enableexternalservices", true)
|
viper.SetDefault("enableexternalservices", true)
|
||||||
viper.SetDefault("enablem3uexternalalbumart", false)
|
viper.SetDefault("enablem3uexternalalbumart", false)
|
||||||
viper.SetDefault("enablemediafilecoverart", true)
|
viper.SetDefault("enablemediafilecoverart", true)
|
||||||
viper.SetDefault("autotranscodedownload", false)
|
viper.SetDefault("autotranscodedownload", false)
|
||||||
|
viper.SetDefault("maxconcurrenttranscodes", max(4, runtime.NumCPU()))
|
||||||
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
viper.SetDefault("defaultdownsamplingformat", consts.DefaultDownsamplingFormat)
|
||||||
viper.SetDefault("search.fullstring", false)
|
viper.SetDefault("search.fullstring", false)
|
||||||
viper.SetDefault("search.backend", "fts")
|
viper.SetDefault("search.backend", "fts")
|
||||||
viper.SetDefault("matcher.preferstarred", true)
|
viper.SetDefault("similarsongsmatchthreshold", 85)
|
||||||
viper.SetDefault("matcher.fuzzythreshold", 85)
|
|
||||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||||
viper.SetDefault("prefersorttags", false)
|
viper.SetDefault("prefersorttags", false)
|
||||||
viper.SetDefault("enablenaturalsorting", false)
|
|
||||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||||
viper.SetDefault("ffmpegpath", "")
|
viper.SetDefault("ffmpegpath", "")
|
||||||
viper.SetDefault("mpvpath", "")
|
|
||||||
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
viper.SetDefault("mpvcmdtemplate", "mpv --audio-device=%d --no-audio-display %f --input-ipc-server=%s")
|
||||||
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
viper.SetDefault("coverartpriority", "cover.*, folder.*, front.*, embedded, external")
|
||||||
viper.SetDefault("coverartquality", 75)
|
viper.SetDefault("coverartquality", 75)
|
||||||
viper.SetDefault("enablewebpencoding", false)
|
|
||||||
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
viper.SetDefault("artistartpriority", "artist.*, album/artist.*, external")
|
||||||
viper.SetDefault("artistimagefolder", "")
|
|
||||||
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
viper.SetDefault("discartpriority", "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded")
|
||||||
viper.SetDefault("lyricspriority", ".ttml,.yaml,.yml,.elrc,.lrc,.srt,.txt,embedded")
|
viper.SetDefault("lyricspriority", ".lrc,.txt,embedded")
|
||||||
viper.SetDefault("enablegravatar", false)
|
viper.SetDefault("enablegravatar", false)
|
||||||
viper.SetDefault("enablefavourites", true)
|
viper.SetDefault("enablefavourites", true)
|
||||||
viper.SetDefault("enablestarrating", true)
|
viper.SetDefault("enablestarrating", true)
|
||||||
|
|
@ -1030,24 +690,19 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("defaultlanguage", "")
|
viper.SetDefault("defaultlanguage", "")
|
||||||
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
viper.SetDefault("defaultuivolume", consts.DefaultUIVolume)
|
||||||
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
viper.SetDefault("uisearchdebouncems", consts.DefaultUISearchDebounceMs)
|
||||||
viper.SetDefault("uicoverartsize", consts.DefaultUICoverArtSize)
|
|
||||||
viper.SetDefault("enablereplaygain", true)
|
viper.SetDefault("enablereplaygain", true)
|
||||||
viper.SetDefault("enablecoveranimation", true)
|
viper.SetDefault("enablecoveranimation", true)
|
||||||
viper.SetDefault("enablenowplaying", true)
|
viper.SetDefault("enablenowplaying", true)
|
||||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
viper.SetDefault("enablecoverartupload", true)
|
||||||
viper.SetDefault("enableartworkupload", true)
|
viper.SetDefault("enablesharing", false)
|
||||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
|
||||||
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
|
||||||
viper.SetDefault("enablesharing", true)
|
|
||||||
viper.SetDefault("shareurl", "")
|
viper.SetDefault("shareurl", "")
|
||||||
viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration)
|
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||||
viper.SetDefault("defaultdownloadableshare", false)
|
viper.SetDefault("defaultdownloadableshare", false)
|
||||||
viper.SetDefault("gatrackingid", "")
|
viper.SetDefault("gatrackingid", "")
|
||||||
viper.SetDefault("enableinsightscollector", true)
|
viper.SetDefault("enableinsightscollector", true)
|
||||||
viper.SetDefault("enablescheduleddbanalyze", true)
|
|
||||||
viper.SetDefault("enablelogredacting", true)
|
viper.SetDefault("enablelogredacting", true)
|
||||||
viper.SetDefault("authrequestlimit", 5)
|
viper.SetDefault("authrequestlimit", 5)
|
||||||
viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength)
|
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||||
viper.SetDefault("passwordencryptionkey", "")
|
viper.SetDefault("passwordencryptionkey", "")
|
||||||
viper.SetDefault("extauth.userheader", "Remote-User")
|
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||||
viper.SetDefault("extauth.trustedsources", "")
|
viper.SetDefault("extauth.trustedsources", "")
|
||||||
|
|
@ -1065,11 +720,9 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
|
viper.SetDefault("scanner.watcherwait", consts.DefaultWatcherWait)
|
||||||
viper.SetDefault("scanner.scanonstartup", true)
|
viper.SetDefault("scanner.scanonstartup", true)
|
||||||
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
|
viper.SetDefault("scanner.artistjoiner", consts.ArtistJoiner)
|
||||||
viper.SetDefault("scanner.artistsplitexceptions", []string{})
|
|
||||||
viper.SetDefault("scanner.genreseparators", "")
|
viper.SetDefault("scanner.genreseparators", "")
|
||||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||||
viper.SetDefault("scanner.followsymlinks", true)
|
viper.SetDefault("scanner.followsymlinks", true)
|
||||||
viper.SetDefault("scanner.ignoredotfolders", true)
|
|
||||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||||
viper.SetDefault("subsonic.appendalbumversion", true)
|
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||||
|
|
@ -1078,9 +731,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("subsonic.enableaveragerating", true)
|
viper.SetDefault("subsonic.enableaveragerating", true)
|
||||||
viper.SetDefault("subsonic.legacyclients", "DSub")
|
viper.SetDefault("subsonic.legacyclients", "DSub")
|
||||||
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
viper.SetDefault("subsonic.minimalclients", "SubMusic")
|
||||||
viper.SetDefault("transcoding.maxconcurrent", 0)
|
|
||||||
viper.SetDefault("transcoding.maxconcurrentperuser", 0)
|
|
||||||
viper.SetDefault("transcoding.enablecancellation", false)
|
|
||||||
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
viper.SetDefault("agents", "deezer,lastfm,listenbrainz")
|
||||||
viper.SetDefault("lastfm.enabled", true)
|
viper.SetDefault("lastfm.enabled", true)
|
||||||
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage)
|
||||||
|
|
@ -1093,10 +743,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
viper.SetDefault("listenbrainz.baseurl", consts.DefaultListenBrainzBaseURL)
|
||||||
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
viper.SetDefault("listenbrainz.artistalgorithm", consts.DefaultListenBrainzArtistAlgorithm)
|
||||||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||||
viper.SetDefault("jellyfin.enabled", false)
|
|
||||||
viper.SetDefault("jellyfin.servername", "")
|
|
||||||
viper.SetDefault("jellyfin.autodiscovery", false)
|
|
||||||
viper.SetDefault("jellyfin.quickconnect", true)
|
|
||||||
viper.SetDefault("enablescrobblehistory", true)
|
viper.SetDefault("enablescrobblehistory", true)
|
||||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||||
viper.SetDefault("backup.path", "")
|
viper.SetDefault("backup.path", "")
|
||||||
|
|
@ -1112,7 +758,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("plugins.enabled", true)
|
viper.SetDefault("plugins.enabled", true)
|
||||||
viper.SetDefault("plugins.cachesize", "200MB")
|
viper.SetDefault("plugins.cachesize", "200MB")
|
||||||
viper.SetDefault("plugins.autoreload", false)
|
viper.SetDefault("plugins.autoreload", false)
|
||||||
viper.SetDefault("plugins.loglevel", "")
|
|
||||||
|
|
||||||
// DevFlags. These are used to enable/disable debugging and incomplete features
|
// DevFlags. These are used to enable/disable debugging and incomplete features
|
||||||
viper.SetDefault("devlogsourceline", false)
|
viper.SetDefault("devlogsourceline", false)
|
||||||
|
|
@ -1125,21 +770,12 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
viper.SetDefault("devapiv1", false)
|
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
viper.SetDefault("devartworkmaxrequests", max(4, runtime.NumCPU()))
|
||||||
// scrobbles and the UI. See MaxOpenConns.
|
|
||||||
viper.SetDefault("jellyfin.maxconcurrentstreams", max(2, MaxOpenConns()/2))
|
|
||||||
viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2))
|
|
||||||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||||
viper.SetDefault("devartworkthrottlebuffered", true)
|
viper.SetDefault("devtranscodethrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||||
// Half the CPU count (min 2), so local resolution scales with the host but stays under the
|
viper.SetDefault("devtranscodethrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||||
// SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI.
|
|
||||||
viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2))
|
|
||||||
// External RPS gates outbound calls to third-party services (per service); it is bounded by
|
|
||||||
// their tolerance, not the host, so it stays a small constant regardless of CPU count.
|
|
||||||
viper.SetDefault("devartworkexternalmaxrps", 2)
|
|
||||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||||
viper.SetDefault("devexternalscanner", true)
|
viper.SetDefault("devexternalscanner", true)
|
||||||
|
|
@ -1150,8 +786,10 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("devenablepluginsinsights", true)
|
viper.SetDefault("devenablepluginsinsights", true)
|
||||||
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
viper.SetDefault("devplugincompilationtimeout", time.Minute)
|
||||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||||
|
viper.SetDefault("devoptimizedb", true)
|
||||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||||
viper.SetDefault("devenablemediafileprobe", true)
|
viper.SetDefault("devenablemediafileprobe", true)
|
||||||
|
viper.SetDefault("devjpegcoverart", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
@ -1188,7 +826,8 @@ func InitConfig(cfgFile string, loadEnvVars bool) {
|
||||||
|
|
||||||
err := viper.ReadInConfig()
|
err := viper.ReadInConfig()
|
||||||
if viper.ConfigFileUsed() != "" && err != nil {
|
if viper.ConfigFileUsed() != "" && err != nil {
|
||||||
logFatal("Navidrome could not open config file:", err)
|
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Navidrome could not open config file: ", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1206,14 +845,3 @@ func getConfigFile(cfgFile string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaxOpenConns is the size of the shared SQLite connection pool, used by every subsystem (scanner,
|
|
||||||
// Subsonic, Jellyfin, native API, UI).
|
|
||||||
//
|
|
||||||
// It bounds concurrent *readers*: SQLite serializes writers on a single database-wide write lock, so
|
|
||||||
// more connections buy no write parallelism. A connection is held while blocked on disk I/O or on a
|
|
||||||
// slow HTTP client, neither of which is CPU-bound — the CPU-bound knob is DevScannerThreads — so the
|
|
||||||
// count is only loosely related to core count, and the floor is what matters on small machines.
|
|
||||||
func MaxOpenConns() int {
|
|
||||||
return max(4, runtime.NumCPU())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,11 @@
|
||||||
package conf_test
|
package conf_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
@ -31,11 +24,6 @@ var _ = Describe("Configuration", func() {
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||||
viper.SetDefault("loglevel", "error")
|
viper.SetDefault("loglevel", "error")
|
||||||
conf.ResetConf()
|
conf.ResetConf()
|
||||||
|
|
||||||
// Panic instead of exiting on fatal errors to allow testing error conditions
|
|
||||||
DeferCleanup(conf.SetLogFatal(func(args ...any) {
|
|
||||||
panic(fmt.Sprint(args...))
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ParseLanguages", func() {
|
Describe("ParseLanguages", func() {
|
||||||
|
|
@ -64,19 +52,6 @@ var _ = Describe("Configuration", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("scheduled DB analysis", func() {
|
|
||||||
It("is enabled by default", func() {
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("can be disabled", func() {
|
|
||||||
viper.Set("enablescheduleddbanalyze", false)
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ValidateURL", func() {
|
Describe("ValidateURL", func() {
|
||||||
It("accepts a valid http URL", func() {
|
It("accepts a valid http URL", func() {
|
||||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
||||||
|
|
@ -133,339 +108,6 @@ var _ = Describe("Configuration", func() {
|
||||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
Entry("falls back to 'fts' for empty string", "", "fts"),
|
||||||
)
|
)
|
||||||
|
|
||||||
DescribeTable("ToPascalCase",
|
|
||||||
func(input, expected string) {
|
|
||||||
Expect(conf.ToPascalCase(input)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("simple key", "address", "Address"),
|
|
||||||
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
|
|
||||||
Entry("already capitalized", "Address", "Address"),
|
|
||||||
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
|
|
||||||
Entry("empty string", "", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
Describe("remapEnvVarKeysFromConfig", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("remaps ND_-prefixed keys to canonical keys", func() {
|
|
||||||
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
|
|
||||||
conf.InitConfig(filename, false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
|
|
||||||
Expect(conf.Server.Port).To(Equal(4531))
|
|
||||||
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("exits with fatal error when both ND_ and canonical key exist", func() {
|
|
||||||
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
|
|
||||||
conf.InitConfig(filename, false)
|
|
||||||
|
|
||||||
Expect(func() { conf.Load(true) }).To(PanicWith(And(
|
|
||||||
ContainSubstring("ND_ADDRESS"),
|
|
||||||
ContainSubstring("Address"),
|
|
||||||
ContainSubstring("only needed for environment variables"),
|
|
||||||
)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does nothing when no ND_ keys are present", func() {
|
|
||||||
filename := filepath.Join("testdata", "cfg.toml")
|
|
||||||
conf.InitConfig(filename, false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
// Verify normal config loading still works
|
|
||||||
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("unknownConfigKeys", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports misplaced and misspelled options, as spelled in the config file", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf(
|
|
||||||
"ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("recovers the original casing in all supported formats",
|
|
||||||
func(file string) {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", file), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption"))
|
|
||||||
},
|
|
||||||
Entry("TOML", "cfg_unknown_casing.toml"),
|
|
||||||
Entry("YAML", "cfg_unknown_casing.yaml"),
|
|
||||||
Entry("JSON", "cfg_unknown_casing.json"),
|
|
||||||
Entry("INI", "cfg_unknown_casing.ini"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("does not report valid, deprecated or free-form keys", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not report the [default] section of INI files", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("SuggestOptions",
|
|
||||||
func(key string, expected []string) {
|
|
||||||
Expect(conf.SuggestOptions(key)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("suggests the section of a misplaced option", "artistsplitexceptions",
|
|
||||||
[]string{"Scanner.ArtistSplitExceptions"}),
|
|
||||||
Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold",
|
|
||||||
[]string{"Matcher.FuzzyThreshold"}),
|
|
||||||
Entry("suggests every section defining the option", "schedule",
|
|
||||||
[]string{"Backup.Schedule", "Scanner.Schedule"}),
|
|
||||||
Entry("suggests nothing for a typo", "enabledownlods", nil),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("does not report ND_-prefixed keys, as they are remapped", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports ND_-prefixed keys that remap to no known option", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION"))
|
|
||||||
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("migrates every deprecated option that has a replacement", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.Search.FullString).To(BeTrue())
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("warns about each unrecognized option at startup", func() {
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
log.SetOutput(&logBuf)
|
|
||||||
DeferCleanup(func() { log.SetOutput(GinkgoWriter) })
|
|
||||||
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(logBuf.String()).To(ContainSubstring(
|
|
||||||
"Option 'ArtistSplitExceptions' is not recognized and will be ignored. " +
|
|
||||||
"Did you mean 'Scanner.ArtistSplitExceptions'?"))
|
|
||||||
Expect(logBuf.String()).To(ContainSubstring(
|
|
||||||
"Option 'EnableDownlods' is not recognized and will be ignored"))
|
|
||||||
Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner"))
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("with runtime-computed and removed options in the config", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports values computed during Load, which the config cannot set", func() {
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("never suggests a removed option", func() {
|
|
||||||
Expect(conf.SuggestOptions("id")).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps an explicit replacement over the deprecated value", func() {
|
|
||||||
Expect(conf.Server.Search.FullString).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("logFatal", func() {
|
|
||||||
var invalidPath string
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
|
|
||||||
// Create a file so that any path under it is invalid on all OSes
|
|
||||||
f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
f.Close()
|
|
||||||
invalidPath = filepath.Join(f.Name(), "subdir")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is called when LoadFromFile gets an invalid config file", func() {
|
|
||||||
Expect(func() {
|
|
||||||
conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
|
|
||||||
}).To(PanicWith(ContainSubstring("Error reading config file")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is called when LogFile path is not writable", func() {
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
|
|
||||||
Expect(func() {
|
|
||||||
conf.Load(true)
|
|
||||||
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("creates the log file readable only by the owner", func() {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
Skip("file modes are not enforced on Windows")
|
|
||||||
}
|
|
||||||
logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log")
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("logfile", logFile)
|
|
||||||
DeferCleanup(log.SetOutput, os.Stderr)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
info, err := os.Stat(logFile)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is called when BaseURL is invalid", func() {
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("baseurl", "://invalid")
|
|
||||||
Expect(func() {
|
|
||||||
conf.Load(true)
|
|
||||||
}).To(PanicWith(ContainSubstring("Invalid BaseURL")))
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ValidateByteSize", func() {
|
|
||||||
DescribeTable("accepts valid size values",
|
|
||||||
func(input string) {
|
|
||||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
|
|
||||||
},
|
|
||||||
Entry("megabytes", "10MB"),
|
|
||||||
Entry("gigabytes", "1GB"),
|
|
||||||
Entry("raw bytes", "10485760"),
|
|
||||||
Entry("mebibytes", "10MiB"),
|
|
||||||
Entry("lower case", "50mb"),
|
|
||||||
)
|
|
||||||
|
|
||||||
DescribeTable("rejects invalid size values",
|
|
||||||
func(input string) {
|
|
||||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
|
|
||||||
},
|
|
||||||
Entry("garbage string", "not-a-size"),
|
|
||||||
Entry("negative-looking", "-10MB"),
|
|
||||||
Entry("zero", "0"),
|
|
||||||
Entry("zero with unit", "0MB"),
|
|
||||||
Entry("overflows int64", "9223372036854775808"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("MaxImageSize floor", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is raised to MaxImageUploadSize when configured lower", func() {
|
|
||||||
viper.SetDefault("maximagesize", "5MB")
|
|
||||||
viper.SetDefault("maximageuploadsize", "50MB")
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.MaxImageSize).To(Equal("50MB"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps a larger MaxImageSize unchanged", func() {
|
|
||||||
viper.SetDefault("maximagesize", "30MB")
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.MaxImageSize).To(Equal("30MB"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Scanner.Extractor", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to taglib for an unknown extractor", func() {
|
|
||||||
viper.SetDefault("scanner.extractor", "ffmpeg")
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps taglib", func() {
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("EnforceNonRootUser", func() {
|
|
||||||
It("defaults to false", func() {
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows startup for non-root users when enabled", func() {
|
|
||||||
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
|
|
||||||
viper.Set("enforcenonrootuser", true)
|
|
||||||
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("exits when enabled and running as root without having created a data folder", func() {
|
|
||||||
// Create a path that doesn't exist yet
|
|
||||||
tempBase := GinkgoT().TempDir()
|
|
||||||
nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
|
|
||||||
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
|
|
||||||
viper.Set("enforcenonrootuser", true)
|
|
||||||
viper.Set("datafolder", nonExistentDataFolder)
|
|
||||||
|
|
||||||
// Attempt to load config as root user - should fail before creating directories
|
|
||||||
Expect(func() {
|
|
||||||
conf.Load(true)
|
|
||||||
}).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
|
|
||||||
|
|
||||||
// Verify that the data folder was NOT created
|
|
||||||
Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is a no-op on non-unix platforms", func() {
|
|
||||||
DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
|
|
||||||
viper.Set("enforcenonrootuser", true)
|
|
||||||
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("should load configuration from",
|
DescribeTable("should load configuration from",
|
||||||
func(format string) {
|
func(format string) {
|
||||||
filename := filepath.Join("testdata", "cfg."+format)
|
filename := filepath.Join("testdata", "cfg."+format)
|
||||||
|
|
@ -492,73 +134,4 @@ var _ = Describe("Configuration", func() {
|
||||||
Entry("INI format", "ini"),
|
Entry("INI format", "ini"),
|
||||||
Entry("JSON format", "json"),
|
Entry("JSON format", "json"),
|
||||||
)
|
)
|
||||||
|
|
||||||
It("should use default values for negative duration fields", func() {
|
|
||||||
filename := filepath.Join("testdata", "invalid_duration.toml")
|
|
||||||
conf.InitConfig(filename, false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
server := conf.Server
|
|
||||||
Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout))
|
|
||||||
Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh))
|
|
||||||
Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration))
|
|
||||||
Expect(server.UIPlaybackReportInterval).To(Equal(consts.DefaultUIPlaybackReportInterval))
|
|
||||||
Expect(server.AuthWindowLength).To(Equal(consts.DefaultAuthWindowLength))
|
|
||||||
Expect(server.Scanner.WatcherWait).To(Equal(consts.DefaultWatcherWait))
|
|
||||||
|
|
||||||
Expect(server.DevActivityPanelUpdateRate).To(Equal(consts.DefaultActivityPanelUpdateRate))
|
|
||||||
Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(consts.RequestThrottleBacklogTimeout))
|
|
||||||
Expect(server.DevArtistInfoTimeToLive).To(Equal(consts.ArtistInfoTimeToLive))
|
|
||||||
Expect(server.DevAlbumInfoTimeToLive).To(Equal(consts.AlbumInfoTimeToLive))
|
|
||||||
Expect(server.DevInsightsInitialDelay).To(Equal(consts.InsightsInitialDelay))
|
|
||||||
Expect(server.DevPluginCompilationTimeout).To(Equal(consts.DefaultPluginCompilationTimeout))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("should use parsed values for duration fields", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "valid_duration.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
configured := 1 * time.Second
|
|
||||||
|
|
||||||
server := conf.Server
|
|
||||||
Expect(server.SessionTimeout).To(Equal(configured))
|
|
||||||
Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured))
|
|
||||||
Expect(server.DefaultShareExpiration).To(Equal(configured))
|
|
||||||
Expect(server.UIPlaybackReportInterval).To(Equal(configured))
|
|
||||||
Expect(server.AuthWindowLength).To(Equal(configured))
|
|
||||||
Expect(server.Scanner.WatcherWait).To(Equal(configured))
|
|
||||||
|
|
||||||
Expect(server.DevActivityPanelUpdateRate).To(Equal(configured))
|
|
||||||
Expect(server.DevArtworkThrottleBacklogTimeout).To(Equal(configured))
|
|
||||||
Expect(server.DevArtistInfoTimeToLive).To(Equal(configured))
|
|
||||||
Expect(server.DevAlbumInfoTimeToLive).To(Equal(configured))
|
|
||||||
Expect(server.DevInsightsInitialDelay).To(Equal(configured))
|
|
||||||
Expect(server.DevPluginCompilationTimeout).To(Equal(configured))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("TLSEnabled", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is false when neither the certificate nor the key is set", func() {
|
|
||||||
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is true when both the certificate and the key are set", func() {
|
|
||||||
conf.Server.TLSCert = "cert.pem"
|
|
||||||
conf.Server.TLSKey = "key.pem"
|
|
||||||
Expect(conf.Server.TLSEnabled()).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is false when only the certificate is set", func() {
|
|
||||||
conf.Server.TLSCert = "cert.pem"
|
|
||||||
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is false when only the key is set", func() {
|
|
||||||
conf.Server.TLSKey = "key.pem"
|
|
||||||
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
77
conf/dir.go
77
conf/dir.go
|
|
@ -1,77 +0,0 @@
|
||||||
package conf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Dir wraps a directory path and creates the directory on demand. Dir is a
|
|
||||||
// plain value type — safe to copy, compare, and print via reflection-based
|
|
||||||
// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards.
|
|
||||||
// Directory creation is delegated to os.MkdirAll on every Path() call;
|
|
||||||
// MkdirAll is idempotent, so repeated calls cost one stat syscall when the
|
|
||||||
// directory already exists.
|
|
||||||
type Dir struct {
|
|
||||||
path string
|
|
||||||
perm os.FileMode
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDir creates a new Dir with the given path and default permissions (os.ModePerm).
|
|
||||||
func NewDir(path string) Dir {
|
|
||||||
return Dir{path: path, perm: os.ModePerm}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDirWithPerm creates a new Dir with the given path and permissions.
|
|
||||||
// A perm of 0 is treated as "default" and resolves to os.ModePerm at
|
|
||||||
// directory-creation time; pass an explicit non-zero mode to constrain the
|
|
||||||
// permissions.
|
|
||||||
func NewDirWithPerm(path string, perm os.FileMode) Dir {
|
|
||||||
return Dir{path: path, perm: perm}
|
|
||||||
}
|
|
||||||
|
|
||||||
// String returns the raw path without creating the directory. Satisfies fmt.Stringer.
|
|
||||||
func (d Dir) String() string {
|
|
||||||
return d.path
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path ensures the directory exists and returns its path. Safe to call
|
|
||||||
// repeatedly; an empty path is returned as-is with no error.
|
|
||||||
func (d Dir) Path() (string, error) {
|
|
||||||
if d.path == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil {
|
|
||||||
return d.path, fmt.Errorf("creating directory %q: %w", d.path, err)
|
|
||||||
}
|
|
||||||
return d.path, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MustPath calls Path() and calls logFatal on error.
|
|
||||||
func (d Dir) MustPath() string {
|
|
||||||
path, err := d.Path()
|
|
||||||
if err != nil {
|
|
||||||
logFatal("creating directory:", err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf)
|
|
||||||
// prints the path string instead of the internal struct fields.
|
|
||||||
func (d Dir) GoString() string {
|
|
||||||
return fmt.Sprintf("%q", d.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MarshalText returns the raw path bytes. No side effects.
|
|
||||||
func (d Dir) MarshalText() ([]byte, error) {
|
|
||||||
return []byte(d.path), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnmarshalText sets the path from bytes. No side effects.
|
|
||||||
func (d *Dir) UnmarshalText(text []byte) error {
|
|
||||||
d.path = string(text)
|
|
||||||
if d.perm == 0 {
|
|
||||||
d.perm = os.ModePerm
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
164
conf/dir_test.go
164
conf/dir_test.go
|
|
@ -1,164 +0,0 @@
|
||||||
package conf_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/kr/pretty"
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("Dir", func() {
|
|
||||||
Describe("NewDir", func() {
|
|
||||||
It("creates a Dir with the given path without side effects", func() {
|
|
||||||
d := conf.NewDir("/some/path")
|
|
||||||
Expect(d.String()).To(Equal("/some/path"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("String", func() {
|
|
||||||
It("returns the raw path without creating the directory", func() {
|
|
||||||
d := conf.NewDir("/nonexistent/path/that/should/not/be/created")
|
|
||||||
Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Path", func() {
|
|
||||||
It("creates the directory and returns the path on first call", func() {
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
target := dir + "/subdir/nested"
|
|
||||||
d := conf.NewDir(target)
|
|
||||||
|
|
||||||
path, err := d.Path()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(path).To(Equal(target))
|
|
||||||
Expect(target).To(BeADirectory())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is idempotent on subsequent calls", func() {
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
target := dir + "/idempotent"
|
|
||||||
d := conf.NewDir(target)
|
|
||||||
|
|
||||||
path1, err1 := d.Path()
|
|
||||||
path2, err2 := d.Path()
|
|
||||||
Expect(err1).ToNot(HaveOccurred())
|
|
||||||
Expect(err2).ToNot(HaveOccurred())
|
|
||||||
Expect(path1).To(Equal(path2))
|
|
||||||
Expect(target).To(BeADirectory())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error when directory cannot be created", func() {
|
|
||||||
f := GinkgoT().TempDir()
|
|
||||||
blocker := f + "/blocker"
|
|
||||||
By("creating a file that blocks directory creation")
|
|
||||||
Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed())
|
|
||||||
invalid := blocker + "/subdir"
|
|
||||||
|
|
||||||
d := conf.NewDir(invalid)
|
|
||||||
_, pathErr := d.Path()
|
|
||||||
Expect(pathErr).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty path and no error for empty path", func() {
|
|
||||||
d := conf.NewDir("")
|
|
||||||
path, err := d.Path()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(path).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("MustPath", func() {
|
|
||||||
It("returns the path when directory is created successfully", func() {
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
target := dir + "/mustpath"
|
|
||||||
d := conf.NewDir(target)
|
|
||||||
|
|
||||||
path := d.MustPath()
|
|
||||||
Expect(path).To(Equal(target))
|
|
||||||
Expect(target).To(BeADirectory())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("calls logFatal on error", func() {
|
|
||||||
var fatalMsg []any
|
|
||||||
restore := conf.SetLogFatal(func(args ...any) {
|
|
||||||
fatalMsg = args
|
|
||||||
panic("logFatal called")
|
|
||||||
})
|
|
||||||
DeferCleanup(restore)
|
|
||||||
|
|
||||||
f := GinkgoT().TempDir() + "/blocker"
|
|
||||||
Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed())
|
|
||||||
invalid := f + "/subdir"
|
|
||||||
|
|
||||||
d := conf.NewDir(invalid)
|
|
||||||
Expect(func() { d.MustPath() }).To(Panic())
|
|
||||||
Expect(fatalMsg).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("MarshalText", func() {
|
|
||||||
It("returns the raw path bytes without side effects", func() {
|
|
||||||
d := conf.NewDir("/marshal/path")
|
|
||||||
b, err := d.MarshalText()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(string(b)).To(Equal("/marshal/path"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("UnmarshalText", func() {
|
|
||||||
It("sets the path from bytes without side effects", func() {
|
|
||||||
d := conf.NewDir("")
|
|
||||||
err := d.UnmarshalText([]byte("/unmarshal/path"))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(d.String()).To(Equal("/unmarshal/path"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows round-trip marshal/unmarshal", func() {
|
|
||||||
d1 := conf.NewDir("/round/trip")
|
|
||||||
b, err := d1.MarshalText()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
var d2 conf.Dir
|
|
||||||
err = d2.UnmarshalText(b)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(d2.String()).To(Equal(d1.String()))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GoString", func() {
|
|
||||||
// Regression: pretty.Sprintf("%# v", ...) is used by the
|
|
||||||
// configuration dump. It must render Dir as a quoted path via
|
|
||||||
// GoString, not dump the internal struct fields.
|
|
||||||
It("renders Dir as a quoted path under pretty.Sprintf", func() {
|
|
||||||
type host struct {
|
|
||||||
DataFolder conf.Dir
|
|
||||||
}
|
|
||||||
h := host{DataFolder: conf.NewDir("./data")}
|
|
||||||
out := pretty.Sprintf("%# v", h)
|
|
||||||
Expect(out).To(ContainSubstring(`DataFolder: "./data"`))
|
|
||||||
Expect(out).ToNot(ContainSubstring("perm:"))
|
|
||||||
Expect(out).ToNot(ContainSubstring("path:"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is safe to copy and use concurrently", func() {
|
|
||||||
// Regression for the Windows "sync: unlock of unlocked mutex"
|
|
||||||
// crash that was caused by copying a Dir embedding sync.Once.
|
|
||||||
// Dir is a plain value type now, but keep the concurrent stress
|
|
||||||
// test to lock in the property.
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
d := conf.NewDir(dir + "/race")
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for range 10 {
|
|
||||||
wg.Go(func() {
|
|
||||||
copy1 := d
|
|
||||||
_ = pretty.Sprintf("%# v", copy1)
|
|
||||||
_, _ = copy1.Path()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -11,28 +11,3 @@ var ParseLanguages = parseLanguages
|
||||||
var ValidateURL = validateURL
|
var ValidateURL = validateURL
|
||||||
|
|
||||||
var NormalizeSearchBackend = normalizeSearchBackend
|
var NormalizeSearchBackend = normalizeSearchBackend
|
||||||
|
|
||||||
var ToPascalCase = toPascalCase
|
|
||||||
|
|
||||||
var ValidateByteSize = validateByteSize
|
|
||||||
|
|
||||||
func SetRuntimeInfoForTest(goos string, euid int) func() {
|
|
||||||
oldGOOS := currentGOOS
|
|
||||||
oldEUID := getEUID
|
|
||||||
currentGOOS = func() string { return goos }
|
|
||||||
getEUID = func() int { return euid }
|
|
||||||
return func() {
|
|
||||||
currentGOOS = oldGOOS
|
|
||||||
getEUID = oldEUID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetLogFatal(f func(...any)) func() {
|
|
||||||
old := logFatal
|
|
||||||
logFatal = f
|
|
||||||
return func() { logFatal = old }
|
|
||||||
}
|
|
||||||
|
|
||||||
var UnknownConfigKeys = unknownConfigKeys
|
|
||||||
|
|
||||||
var SuggestOptions = suggestOptions
|
|
||||||
|
|
|
||||||
2
conf/testdata/cfg_deprecated_search.toml
vendored
2
conf/testdata/cfg_deprecated_search.toml
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
MusicFolder = "/toml/music"
|
|
||||||
SearchFullString = true
|
|
||||||
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