mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-13 04:47:17 +02:00
Compare commits
3 commits
master
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7c644c0f | ||
|
|
866ff8468c | ||
|
|
9d4af721ca |
1914 changed files with 58410 additions and 210104 deletions
|
|
@ -13,5 +13,15 @@ RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/shar
|
||||||
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||||
&& apt-get -y install --no-install-recommends ffmpeg
|
&& apt-get -y install --no-install-recommends ffmpeg
|
||||||
|
|
||||||
|
# Install TagLib from cross-taglib releases
|
||||||
|
ARG CROSS_TAGLIB_VERSION="2.1.1-1"
|
||||||
|
ARG TARGETARCH
|
||||||
|
RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
|
||||||
|
&& wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
|
||||||
|
&& tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
|
||||||
|
&& mv /usr/include/taglib/* /usr/include/ \
|
||||||
|
&& rmdir /usr/include/taglib \
|
||||||
|
&& rm /tmp/cross-taglib.tar.gz /usr/provenance.json
|
||||||
|
|
||||||
# [Optional] Uncomment this line to install global node packages.
|
# [Optional] Uncomment this line to install global node packages.
|
||||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,11 @@
|
||||||
"dockerfile": "Dockerfile",
|
"dockerfile": "Dockerfile",
|
||||||
"args": {
|
"args": {
|
||||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||||
"VARIANT": "1.27",
|
"VARIANT": "1.25",
|
||||||
// Options
|
// Options
|
||||||
"INSTALL_NODE": "true",
|
"INSTALL_NODE": "true",
|
||||||
"NODE_VERSION": "v24"
|
"NODE_VERSION": "v24",
|
||||||
|
"CROSS_TAGLIB_VERSION": "2.1.1-1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"workspaceMount": "",
|
"workspaceMount": "",
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,4 @@ dist
|
||||||
binaries
|
binaries
|
||||||
cache
|
cache
|
||||||
music
|
music
|
||||||
music.old
|
|
||||||
!Dockerfile
|
!Dockerfile
|
||||||
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});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
310
.github/workflows/pipeline.yml
vendored
310
.github/workflows/pipeline.yml
vendored
|
|
@ -14,6 +14,7 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
CROSS_TAGLIB_VERSION: "2.1.1-1"
|
||||||
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
IS_RELEASE: ${{ startsWith(github.ref, 'refs/tags/') && 'true' || 'false' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -24,7 +25,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 +33,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 +41,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 +63,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,234 +88,25 @@ 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
|
|
||||||
run: |
|
|
||||||
make api-gen
|
|
||||||
go generate ./...
|
|
||||||
- name: Verify no changes from go generate
|
|
||||||
run: |
|
|
||||||
git status --porcelain
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo 'Generated code is out of date. Run "make gen" and commit the changes'
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Check for breaking OpenAPI changes
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
|
||||||
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
|
||||||
|
|
||||||
validate-migrations:
|
|
||||||
name: Validate DB migrations
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
# PR-only gate is at step level: a job-level skip would propagate through
|
|
||||||
# the needs chain (actions/runner#491) and skip all release jobs on tag pushes.
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
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
|
|
||||||
# by name across every job, and waits for each match to finish.
|
|
||||||
- name: Test with coverage
|
|
||||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
|
|
||||||
|
|
||||||
- name: Test ndpgen
|
|
||||||
run: |
|
|
||||||
cd plugins/cmd/ndpgen
|
|
||||||
go test -shuffle=on -v
|
|
||||||
go build -o ndpgen .
|
|
||||||
./ndpgen --help
|
|
||||||
|
|
||||||
- name: Upload coverage profile
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: octocov-go
|
|
||||||
path: coverage.out
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
go-plugins:
|
|
||||||
name: Test Go plugins
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Check out code into the Go module directory
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
id: setup-go
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
# Without this, the suite recompiles every test plugin WASM module,
|
|
||||||
# which dominates its runtime under -race.
|
|
||||||
- name: Cache the WASM compilation cache
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: plugins/testdata/.wazero-cache
|
|
||||||
key: wazero-${{ runner.os }}-go${{ steps.setup-go.outputs.go-version }}-${{ hashFiles('plugins/testdata/*/*.go', 'plugins/testdata/*/go.*', 'plugins/pdk/go/**/*.go', 'plugins/pdk/go/go.*') }}
|
|
||||||
restore-keys: wazero-${{ runner.os }}-
|
|
||||||
|
|
||||||
- name: Test plugins
|
|
||||||
run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/
|
|
||||||
|
|
||||||
- name: Upload coverage profile
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: octocov-plugins
|
|
||||||
path: coverage.out
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
coverage:
|
|
||||||
name: Report coverage
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [go, go-plugins]
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
env:
|
|
||||||
COVERAGE_COMMENT: 'false'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
pattern: octocov-*
|
|
||||||
|
|
||||||
# Merge here rather than letting octocov do it: octocov reports statement
|
|
||||||
# coverage for a single profile, but switches to line counting for several.
|
|
||||||
- name: Merge coverage profiles
|
|
||||||
run: |
|
|
||||||
echo "mode: atomic" > coverage.out
|
|
||||||
awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \
|
|
||||||
octocov-*/coverage.out | sort >> coverage.out
|
|
||||||
|
|
||||||
- uses: k1LoW/octocov-action@v1
|
|
||||||
|
|
||||||
- name: Save the PR number for the comment workflow
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: echo "${{ github.event.pull_request.number }}" > pr_number
|
|
||||||
|
|
||||||
- name: Upload the merged profile for the comment workflow
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: octocov-pr
|
|
||||||
path: |
|
|
||||||
coverage.out
|
|
||||||
pr_number
|
|
||||||
if-no-files-found: error
|
|
||||||
|
|
||||||
go-windows:
|
|
||||||
name: Test Go code (Windows)
|
|
||||||
runs-on: windows-2022
|
|
||||||
env:
|
|
||||||
FFMPEG_VERSION: "7.1"
|
|
||||||
FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version-file: go.mod
|
|
||||||
|
|
||||||
- uses: msys2/setup-msys2@v2
|
|
||||||
with:
|
|
||||||
msystem: MINGW64
|
|
||||||
install: mingw-w64-x86_64-gcc
|
|
||||||
update: false
|
|
||||||
|
|
||||||
- name: Add mingw64 to PATH
|
|
||||||
shell: bash
|
|
||||||
run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Cache ffmpeg
|
|
||||||
id: ffmpeg-cache
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: C:\ffmpeg
|
|
||||||
key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64
|
|
||||||
|
|
||||||
- name: Download ffmpeg
|
|
||||||
if: steps.ffmpeg-cache.outputs.cache-hit != 'true'
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}"
|
|
||||||
$url = "https://github.com/${env:FFMPEG_REPOSITORY}/releases/download/latest/$asset.zip"
|
|
||||||
Invoke-WebRequest -Uri $url -OutFile ffmpeg.zip
|
|
||||||
Expand-Archive ffmpeg.zip -DestinationPath C:\ffmpeg-extracted
|
|
||||||
New-Item -ItemType Directory -Force -Path C:\ffmpeg\bin | Out-Null
|
|
||||||
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffmpeg.exe" C:\ffmpeg\bin
|
|
||||||
Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin
|
|
||||||
|
|
||||||
- name: Add ffmpeg to PATH
|
|
||||||
shell: bash
|
|
||||||
run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH
|
|
||||||
|
|
||||||
- name: Verify toolchain
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
go version
|
|
||||||
where.exe gcc
|
|
||||||
gcc --version
|
|
||||||
ffmpeg -version
|
|
||||||
ffprobe -version
|
|
||||||
|
|
||||||
- name: Download dependencies
|
|
||||||
shell: bash
|
|
||||||
run: go mod download
|
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
CGO_ENABLED: "1"
|
|
||||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
|
|
||||||
|
|
||||||
- name: Test ndpgen
|
|
||||||
shell: bash
|
|
||||||
run: |
|
run: |
|
||||||
cd plugins/cmd/ndpgen
|
pkg-config --define-prefix --cflags --libs taglib # for debugging
|
||||||
go test -shuffle=on -v
|
go test -shuffle=on -tags netgo -race ./... -v
|
||||||
go build -o ndpgen.exe .
|
|
||||||
./ndpgen.exe --help
|
|
||||||
|
|
||||||
js:
|
js:
|
||||||
name: Test JS code
|
name: Test JS code
|
||||||
|
|
@ -327,7 +114,7 @@ jobs:
|
||||||
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 +145,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,10 +172,10 @@ 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, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }}
|
||||||
|
|
@ -404,7 +191,7 @@ jobs:
|
||||||
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
|
||||||
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Prepare Docker Buildx
|
- name: Prepare Docker Buildx
|
||||||
uses: ./.github/actions/prepare-docker
|
uses: ./.github/actions/prepare-docker
|
||||||
|
|
@ -416,7 +203,7 @@ jobs:
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Build Binaries
|
- name: Build Binaries
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|
@ -427,24 +214,10 @@ jobs:
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
- name: Set up QEMU for smoke test
|
|
||||||
if: env.IS_LINUX == 'true'
|
|
||||||
uses: docker/setup-qemu-action@v4
|
|
||||||
|
|
||||||
# The binary is static, so binfmt+qemu runs it directly on the runner.
|
|
||||||
# Catches startup crashes in cross-compiled binaries before they ship,
|
|
||||||
# e.g. the broken ifunc relocations on 32-bit arm from issue #5738.
|
|
||||||
- name: Smoke-test binary
|
|
||||||
if: env.IS_LINUX == 'true'
|
|
||||||
run: |
|
|
||||||
BIN=./output/${{ env.PLATFORM }}/navidrome
|
|
||||||
chmod +x "$BIN"
|
|
||||||
"$BIN" --help >/dev/null
|
|
||||||
echo "OK: ${{ matrix.platform }} binary starts"
|
|
||||||
|
|
||||||
- name: Upload Binaries
|
- name: Upload Binaries
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome-${{ env.PLATFORM }}
|
name: navidrome-${{ env.PLATFORM }}
|
||||||
path: ./output
|
path: ./output
|
||||||
|
|
@ -453,7 +226,7 @@ jobs:
|
||||||
- name: Build and push image by digest
|
- name: Build and push image by digest
|
||||||
id: push-image
|
id: push-image
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: Dockerfile
|
file: Dockerfile
|
||||||
|
|
@ -462,6 +235,7 @@ jobs:
|
||||||
build-args: |
|
build-args: |
|
||||||
GIT_SHA=${{ env.GIT_SHA }}
|
GIT_SHA=${{ env.GIT_SHA }}
|
||||||
GIT_TAG=${{ env.GIT_TAG }}
|
GIT_TAG=${{ env.GIT_TAG }}
|
||||||
|
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
|
||||||
outputs: |
|
outputs: |
|
||||||
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
|
type=image,name=${{ steps.docker.outputs.hub_repository }},push-by-digest=true,name-canonical=true,push=${{ steps.docker.outputs.hub_enabled }}
|
||||||
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
@ -474,7 +248,7 @@ jobs:
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
|
||||||
with:
|
with:
|
||||||
name: digests-${{ env.PLATFORM }}
|
name: digests-${{ env.PLATFORM }}
|
||||||
|
|
@ -493,10 +267,10 @@ jobs:
|
||||||
env:
|
env:
|
||||||
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
@ -527,10 +301,10 @@ jobs:
|
||||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Download digests
|
- name: Download digests
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: /tmp/digests
|
path: /tmp/digests
|
||||||
pattern: digests-*
|
pattern: digests-*
|
||||||
|
|
@ -546,7 +320,7 @@ jobs:
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
- name: Create manifest list and push to Docker Hub
|
- name: Create manifest list and push to Docker Hub
|
||||||
uses: nick-fields/retry@v4
|
uses: nick-fields/retry@v3
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 5
|
timeout_minutes: 5
|
||||||
max_attempts: 3
|
max_attempts: 3
|
||||||
|
|
@ -570,7 +344,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
@ -580,9 +354,9 @@ jobs:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-windows*
|
pattern: navidrome-windows*
|
||||||
|
|
@ -601,7 +375,7 @@ jobs:
|
||||||
du -h binaries/msi/*.msi
|
du -h binaries/msi/*.msi
|
||||||
|
|
||||||
- name: Upload MSI files
|
- name: Upload MSI files
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome-windows-installers
|
name: navidrome-windows-installers
|
||||||
path: binaries/msi/*.msi
|
path: binaries/msi/*.msi
|
||||||
|
|
@ -614,12 +388,12 @@ jobs:
|
||||||
outputs:
|
outputs:
|
||||||
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
package_list: ${{ steps.set-package-list.outputs.package_list }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
fetch-tags: true
|
fetch-tags: true
|
||||||
|
|
||||||
- uses: actions/download-artifact@v8
|
- uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: ./binaries
|
path: ./binaries
|
||||||
pattern: navidrome-*
|
pattern: navidrome-*
|
||||||
|
|
@ -632,9 +406,9 @@ jobs:
|
||||||
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v7
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: '2.16.0'
|
version: '~> v2'
|
||||||
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
args: "release --clean -f release/goreleaser.yml ${{ env.RELEASE_FLAGS }}"
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
@ -645,7 +419,7 @@ jobs:
|
||||||
rm ./dist/*.tar.gz ./dist/*.zip
|
rm ./dist/*.tar.gz ./dist/*.zip
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: dist/navidrome_0*
|
path: dist/navidrome_0*
|
||||||
|
|
@ -668,13 +442,13 @@ jobs:
|
||||||
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
item: ${{ fromJson(needs.release.outputs.package_list) }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download all-packages artifact
|
- name: Download all-packages artifact
|
||||||
uses: actions/download-artifact@v8
|
uses: actions/download-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: packages
|
name: packages
|
||||||
path: ./dist
|
path: ./dist
|
||||||
|
|
||||||
- name: Upload all-packages artifact
|
- name: Upload all-packages artifact
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: navidrome_linux_${{ matrix.item }}
|
name: navidrome_linux_${{ matrix.item }}
|
||||||
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
path: dist/navidrome_0*_linux_${{ matrix.item }}
|
||||||
|
|
|
||||||
138
.github/workflows/push-translations.sh
vendored
138
.github/workflows/push-translations.sh
vendored
|
|
@ -1,138 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
I18N_DIR=resources/i18n
|
|
||||||
|
|
||||||
# Normalize JSON for deterministic comparison:
|
|
||||||
# remove empty/null attributes, sort keys alphabetically
|
|
||||||
process_json() {
|
|
||||||
jq 'walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != [] and .value != {})) | to_entries | sort_by(.key) | from_entries else . end)' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get list of all languages configured in the POEditor project
|
|
||||||
get_language_list() {
|
|
||||||
curl -s -X POST https://api.poeditor.com/v2/languages/list \
|
|
||||||
-d api_token="${POEDITOR_APIKEY}" \
|
|
||||||
-d id="${POEDITOR_PROJECTID}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract language name from the language list JSON given a language code
|
|
||||||
get_language_name() {
|
|
||||||
lang_code="$1"
|
|
||||||
lang_list="$2"
|
|
||||||
echo "$lang_list" | jq -r ".result.languages[] | select(.code == \"$lang_code\") | .name"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract language code from a file path (e.g., "resources/i18n/fr.json" -> "fr")
|
|
||||||
get_lang_code() {
|
|
||||||
filepath="$1"
|
|
||||||
filename=$(basename "$filepath")
|
|
||||||
echo "${filename%.*}"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Export the current translation for a language from POEditor (v2 API)
|
|
||||||
export_language() {
|
|
||||||
lang_code="$1"
|
|
||||||
response=$(curl -s -X POST https://api.poeditor.com/v2/projects/export \
|
|
||||||
-d api_token="${POEDITOR_APIKEY}" \
|
|
||||||
-d id="${POEDITOR_PROJECTID}" \
|
|
||||||
-d language="$lang_code" \
|
|
||||||
-d type="key_value_json")
|
|
||||||
|
|
||||||
url=$(echo "$response" | jq -r '.result.url')
|
|
||||||
if [ -z "$url" ] || [ "$url" = "null" ]; then
|
|
||||||
echo "Failed to export $lang_code: $response" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
echo "$url"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Flatten nested JSON to POEditor languages/update format.
|
|
||||||
# POEditor uses term + context pairs, where:
|
|
||||||
# term = the leaf key name
|
|
||||||
# context = the parent path as "key1"."key2"."key3" (empty for root keys)
|
|
||||||
flatten_to_poeditor() {
|
|
||||||
jq -c '[paths(scalars) as $p |
|
|
||||||
{
|
|
||||||
"term": ($p | last | tostring),
|
|
||||||
"context": (if ($p | length) > 1 then ($p[:-1] | map("\"" + tostring + "\"") | join(".")) else "" end),
|
|
||||||
"translation": {"content": getpath($p)}
|
|
||||||
}
|
|
||||||
]' "$1"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Update translations for a language in POEditor via languages/update API
|
|
||||||
update_language() {
|
|
||||||
lang_code="$1"
|
|
||||||
file="$2"
|
|
||||||
|
|
||||||
flatten_to_poeditor "$file" > /tmp/poeditor_data.json
|
|
||||||
response=$(curl -s -X POST https://api.poeditor.com/v2/languages/update \
|
|
||||||
-d api_token="${POEDITOR_APIKEY}" \
|
|
||||||
-d id="${POEDITOR_PROJECTID}" \
|
|
||||||
-d language="$lang_code" \
|
|
||||||
--data-urlencode data@/tmp/poeditor_data.json)
|
|
||||||
rm -f /tmp/poeditor_data.json
|
|
||||||
|
|
||||||
status=$(echo "$response" | jq -r '.response.status')
|
|
||||||
if [ "$status" != "success" ]; then
|
|
||||||
echo "Failed to update $lang_code: $response" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
parsed=$(echo "$response" | jq -r '.result.translations.parsed')
|
|
||||||
added=$(echo "$response" | jq -r '.result.translations.added')
|
|
||||||
updated=$(echo "$response" | jq -r '.result.translations.updated')
|
|
||||||
echo " Translations - parsed: $parsed, added: $added, updated: $updated"
|
|
||||||
}
|
|
||||||
|
|
||||||
# --- Main ---
|
|
||||||
|
|
||||||
if [ $# -eq 0 ]; then
|
|
||||||
echo "Usage: $0 <file1> [file2] ..."
|
|
||||||
echo "No files specified. Nothing to do."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
lang_list=$(get_language_list)
|
|
||||||
upload_count=0
|
|
||||||
|
|
||||||
for file in "$@"; do
|
|
||||||
if [ ! -f "$file" ]; then
|
|
||||||
echo "Warning: File not found: $file, skipping"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
lang_code=$(get_lang_code "$file")
|
|
||||||
lang_name=$(get_language_name "$lang_code" "$lang_list")
|
|
||||||
|
|
||||||
if [ -z "$lang_name" ]; then
|
|
||||||
echo "Warning: Language code '$lang_code' not found in POEditor, skipping $file"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Processing $lang_name ($lang_code)..."
|
|
||||||
|
|
||||||
# Export current state from POEditor
|
|
||||||
url=$(export_language "$lang_code")
|
|
||||||
curl -sSL "$url" -o poeditor_export.json
|
|
||||||
|
|
||||||
# Normalize both files for comparison
|
|
||||||
process_json "$file" > local_normalized.json
|
|
||||||
process_json poeditor_export.json > remote_normalized.json
|
|
||||||
|
|
||||||
# Compare normalized versions
|
|
||||||
if diff -q local_normalized.json remote_normalized.json > /dev/null 2>&1; then
|
|
||||||
echo " No differences, skipping"
|
|
||||||
else
|
|
||||||
echo " Differences found, updating POEditor..."
|
|
||||||
update_language "$lang_code" "$file"
|
|
||||||
upload_count=$((upload_count + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f poeditor_export.json local_normalized.json remote_normalized.json
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "Done. Updated $upload_count translation(s) in POEditor."
|
|
||||||
32
.github/workflows/push-translations.yml
vendored
32
.github/workflows/push-translations.yml
vendored
|
|
@ -1,32 +0,0 @@
|
||||||
name: POEditor export
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
paths:
|
|
||||||
- 'resources/i18n/*.json'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
push-translations:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.repository_owner == 'navidrome' }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7
|
|
||||||
with:
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- name: Detect changed translation files
|
|
||||||
id: changed
|
|
||||||
run: |
|
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD -- 'resources/i18n/*.json' | tr '\n' ' ')
|
|
||||||
echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT
|
|
||||||
echo "Changed translation files: $CHANGED_FILES"
|
|
||||||
|
|
||||||
- name: Push translations to POEditor
|
|
||||||
if: ${{ steps.changed.outputs.files != '' }}
|
|
||||||
env:
|
|
||||||
POEDITOR_APIKEY: ${{ secrets.POEDITOR_APIKEY }}
|
|
||||||
POEDITOR_PROJECTID: ${{ secrets.POEDITOR_PROJECTID }}
|
|
||||||
run: |
|
|
||||||
.github/workflows/push-translations.sh ${{ steps.changed.outputs.files }}
|
|
||||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
|
|
@ -12,7 +12,7 @@ jobs:
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: dessant/lock-threads@v6
|
- uses: dessant/lock-threads@v5
|
||||||
with:
|
with:
|
||||||
process-only: 'issues, prs'
|
process-only: 'issues, prs'
|
||||||
issue-inactive-days: 120
|
issue-inactive-days: 120
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
4
.github/workflows/update-translations.yml
vendored
4
.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:
|
||||||
|
|
@ -24,7 +24,7 @@ jobs:
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
git diff
|
git diff
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v8
|
uses: peter-evans/create-pull-request@v7
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PAT }}
|
token: ${{ secrets.PAT }}
|
||||||
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
author: "navidrome-bot <navidrome-bot@navidrome.org>"
|
||||||
|
|
|
||||||
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"
|
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -17,17 +17,14 @@ master.zip
|
||||||
testDB
|
testDB
|
||||||
cache/*
|
cache/*
|
||||||
*.swp
|
*.swp
|
||||||
coverage.out
|
|
||||||
dist
|
dist
|
||||||
music
|
music
|
||||||
music.old
|
|
||||||
*.db*
|
*.db*
|
||||||
.gitinfo
|
.gitinfo
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
!contrib/docker-compose.yml
|
!contrib/docker-compose.yml
|
||||||
binaries
|
binaries
|
||||||
navidrome-*
|
navidrome-*
|
||||||
/ndpgen
|
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.github/prompts
|
.github/prompts
|
||||||
.github/instructions
|
.github/instructions
|
||||||
|
|
@ -35,16 +32,4 @@ AGENTS.md
|
||||||
*.exe
|
*.exe
|
||||||
*.test
|
*.test
|
||||||
*.wasm
|
*.wasm
|
||||||
*.ndp
|
openspec/
|
||||||
openspec/
|
|
||||||
.agents
|
|
||||||
go.work*
|
|
||||||
.worktrees/
|
|
||||||
.playwright-mcp/
|
|
||||||
|
|
||||||
# Temp benchmark files
|
|
||||||
zz_*_test.go
|
|
||||||
|
|
||||||
# wazero compilation cache for the plugins test suite
|
|
||||||
/plugins/testdata/.wazero-cache/
|
|
||||||
/plugins/testdata/*.stage/
|
|
||||||
|
|
@ -2,19 +2,16 @@ version: "2"
|
||||||
run:
|
run:
|
||||||
build-tags:
|
build-tags:
|
||||||
- netgo
|
- netgo
|
||||||
- sqlite_fts5
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
enable:
|
||||||
- asasalint
|
- asasalint
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- containedctx
|
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
- errorlint
|
- errorlint
|
||||||
- forbidigo
|
|
||||||
- gocritic
|
- gocritic
|
||||||
- gocyclo
|
- gocyclo
|
||||||
- goprintffuncname
|
- goprintffuncname
|
||||||
|
|
@ -28,9 +25,6 @@ linters:
|
||||||
disable:
|
disable:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
settings:
|
settings:
|
||||||
errcheck:
|
|
||||||
exclude-functions:
|
|
||||||
- (*github.com/zeebo/xxh3.Hasher).Write
|
|
||||||
gocritic:
|
gocritic:
|
||||||
disable-all: true
|
disable-all: true
|
||||||
enabled-checks:
|
enabled-checks:
|
||||||
|
|
@ -41,29 +35,10 @@ linters:
|
||||||
- G401
|
- G401
|
||||||
- G505
|
- G505
|
||||||
- G115
|
- G115
|
||||||
forbidigo:
|
|
||||||
forbid:
|
|
||||||
- pattern: 'tx\.Exec$'
|
|
||||||
msg: "use tx.ExecContext(ctx, ...) in migrations to propagate context"
|
|
||||||
- pattern: 'tx\.Query$'
|
|
||||||
msg: "use tx.QueryContext(ctx, ...) in migrations to propagate context"
|
|
||||||
- pattern: 'tx\.QueryRow$'
|
|
||||||
msg: "use tx.QueryRowContext(ctx, ...) in migrations to propagate context"
|
|
||||||
govet:
|
govet:
|
||||||
enable:
|
enable:
|
||||||
- nilness
|
- nilness
|
||||||
exclusions:
|
exclusions:
|
||||||
rules:
|
|
||||||
- linters:
|
|
||||||
- gosec
|
|
||||||
path: _test\.go
|
|
||||||
text: "G703"
|
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- containedctx
|
|
||||||
- path-except: 'db/migrations/'
|
|
||||||
linters:
|
|
||||||
- forbidigo
|
|
||||||
generated: lax
|
generated: lax
|
||||||
presets:
|
presets:
|
||||||
- comments
|
- comments
|
||||||
|
|
@ -74,9 +49,6 @@ linters:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
- node_modules
|
|
||||||
- _gen\.go$
|
|
||||||
- .worktrees
|
|
||||||
formatters:
|
formatters:
|
||||||
exclusions:
|
exclusions:
|
||||||
generated: lax
|
generated: lax
|
||||||
|
|
@ -84,4 +56,3 @@ formatters:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
- examples$
|
- examples$
|
||||||
- node_modules
|
|
||||||
|
|
|
||||||
44
.octocov.yml
44
.octocov.yml
|
|
@ -1,44 +0,0 @@
|
||||||
# Code coverage reporting for pull requests. See https://github.com/k1LoW/octocov
|
|
||||||
# The 30s default is not enough: scanning this repo's artifacts for the baseline
|
|
||||||
# eats most of it, leaving none for the report upload.
|
|
||||||
timeout: 5m
|
|
||||||
coverage:
|
|
||||||
# A single pre-merged profile: octocov reports statements for one path, but
|
|
||||||
# switches to line counting when it merges several itself.
|
|
||||||
paths:
|
|
||||||
- coverage.out
|
|
||||||
# Not code under test: tests/ holds the mocks and helpers, *_gen.go is generated.
|
|
||||||
# Both patterns need the '**/' prefix: the comment workflow has no source tree,
|
|
||||||
# so octocov cannot shorten the profile's import paths to repo-relative ones.
|
|
||||||
exclude:
|
|
||||||
- '**/tests/**'
|
|
||||||
- '**/*_gen.go'
|
|
||||||
codeToTestRatio:
|
|
||||||
# Needs the pull request's own source, which the comment workflow must not
|
|
||||||
# check out: it holds a write token.
|
|
||||||
if: env.COVERAGE_COMMENT != 'true'
|
|
||||||
code:
|
|
||||||
- '**/*.go'
|
|
||||||
- '!**/*_test.go'
|
|
||||||
- '!**/*_gen.go'
|
|
||||||
test:
|
|
||||||
- '**/*_test.go'
|
|
||||||
testExecutionTime:
|
|
||||||
if: true
|
|
||||||
steps:
|
|
||||||
- Test with coverage
|
|
||||||
- Test plugins
|
|
||||||
diff:
|
|
||||||
datastores:
|
|
||||||
- artifact://${GITHUB_REPOSITORY}
|
|
||||||
comment:
|
|
||||||
# Only the 'Report coverage on PR' workflow sets this: a pull_request run from
|
|
||||||
# a fork gets a read-only token, so commenting from here 403s.
|
|
||||||
if: env.COVERAGE_COMMENT == 'true'
|
|
||||||
updatePrevious: true
|
|
||||||
summary:
|
|
||||||
if: true
|
|
||||||
report:
|
|
||||||
if: is_default_branch
|
|
||||||
datastores:
|
|
||||||
- artifact://${GITHUB_REPOSITORY}
|
|
||||||
|
|
@ -38,7 +38,7 @@ Before submitting a pull request, ensure that you go through the following:
|
||||||
### Commit Conventions
|
### Commit Conventions
|
||||||
Each commit message must adhere to the following format:
|
Each commit message must adhere to the following format:
|
||||||
```
|
```
|
||||||
<type>(scope): <description>
|
<type>(scope): <description> - <issue number>
|
||||||
|
|
||||||
[optional body]
|
[optional body]
|
||||||
```
|
```
|
||||||
|
|
|
||||||
143
Dockerfile
143
Dockerfile
|
|
@ -2,10 +2,10 @@ 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.19 AS xx-build
|
||||||
|
|
||||||
# v1.9.0
|
# v1.5.0
|
||||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
ENV XX_VERSION=b4e4c451c778822e6742bfc9d9a91d7c7d885c8a
|
||||||
|
|
||||||
RUN apk add -U --no-cache git
|
RUN apk add -U --no-cache git
|
||||||
RUN git clone https://github.com/tonistiigi/xx && \
|
RUN git clone https://github.com/tonistiigi/xx && \
|
||||||
|
|
@ -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.19 AS taglib-build
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
ARG CROSS_TAGLIB_VERSION=2.1.1-1
|
||||||
|
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
|
||||||
|
|
||||||
|
# wget in busybox can't follow redirects
|
||||||
|
RUN <<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-bookworm 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,14 @@ 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 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 +104,14 @@ RUN --mount=type=bind,source=. \
|
||||||
export CXX=$(xx-info)-g++
|
export CXX=$(xx-info)-g++
|
||||||
export LD_EXTRA="-extldflags '-static -latomic'"
|
export LD_EXTRA="-extldflags '-static -latomic'"
|
||||||
fi
|
fi
|
||||||
# GNU ld corrupts the R_ARM_IRELATIVE addends of libatomic's ifunc resolvers
|
|
||||||
# (wrong address, Thumb bit lost) once .text outgrows the 16MB Thumb branch
|
|
||||||
# range, making static arm binaries jump to garbage inside glibc's ifunc
|
|
||||||
# resolution and crash before main() (issue #5738). Link 32-bit arm with LLD,
|
|
||||||
# which emits correct addends.
|
|
||||||
if [ "$(xx-info arch)" = "arm" ]; then
|
|
||||||
export LD_EXTRA="-extldflags '-static -latomic -fuse-ld=lld'"
|
|
||||||
fi
|
|
||||||
if [ "$(xx-info os)" = "windows" ]; then
|
if [ "$(xx-info os)" = "windows" ]; then
|
||||||
export EXT=".exe"
|
export EXT=".exe"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BUILD_TAGS=$(./release/build-tags.sh)
|
go build -tags=netgo -ldflags="${LD_EXTRA} -w -s \
|
||||||
go build -tags="${BUILD_TAGS}" -ldflags="${LD_EXTRA} -w -s \
|
|
||||||
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
-X github.com/navidrome/navidrome/consts.gitSha=${GIT_SHA} \
|
||||||
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
-X github.com/navidrome/navidrome/consts.gitTag=${GIT_TAG}" \
|
||||||
-o /out/navidrome${EXT} .
|
-o /out/navidrome${EXT} .
|
||||||
# Fail the build if native libwebp (purego) leaked into a 32-bit binary (issue #5738).
|
|
||||||
./release/verify-binary.sh /out/navidrome*
|
|
||||||
EOT
|
EOT
|
||||||
|
|
||||||
# Verify if the binary was built for the correct platform and it is statically linked
|
# Verify if the binary was built for the correct platform and it is statically linked
|
||||||
|
|
@ -152,55 +120,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.19 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 +141,6 @@ RUN touch /.nddockerenv
|
||||||
|
|
||||||
EXPOSE ${ND_PORT}
|
EXPOSE ${ND_PORT}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV PATH="/app:${PATH}"
|
|
||||||
|
|
||||||
ENTRYPOINT ["/app/navidrome"]
|
ENTRYPOINT ["/app/navidrome"]
|
||||||
|
|
||||||
|
|
|
||||||
154
Makefile
154
Makefile
|
|
@ -1,30 +1,22 @@
|
||||||
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
GO_VERSION=$(shell grep "^go " go.mod | cut -f 2 -d ' ')
|
||||||
NODE_VERSION=$(shell cat .nvmrc)
|
NODE_VERSION=$(shell cat .nvmrc)
|
||||||
|
|
||||||
comma:=,
|
|
||||||
GO_BUILD_TAGS=netgo,sqlite_fts5$(if $(EXTRA_BUILD_TAGS),$(comma)$(EXTRA_BUILD_TAGS))
|
|
||||||
|
|
||||||
# Set global environment variables, required for most targets
|
|
||||||
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
|
||||||
endif
|
endif
|
||||||
|
|
||||||
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
DOCKER_TAG ?= deluan/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
|
||||||
VACUUM_VERSION ?= v0.30.6
|
CROSS_TAGLIB_VERSION ?= 2.1.1-1
|
||||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
GOLANGCI_LINT_VERSION ?= v2.6.2
|
||||||
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/*")
|
||||||
|
|
||||||
|
|
@ -34,11 +26,11 @@ setup: check_env download-deps install-golangci-lint setup-git ##@1_Run_First In
|
||||||
.PHONY: setup
|
.PHONY: setup
|
||||||
|
|
||||||
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
dev: check_env ##@Development Start Navidrome in development mode, with hot-reload for both frontend and backend
|
||||||
npx foreman -j Procfile.dev -p 4533 start
|
ND_ENABLEINSIGHTSCOLLECTOR="false" npx foreman -j Procfile.dev -p 4533 start
|
||||||
.PHONY: dev
|
.PHONY: dev
|
||||||
|
|
||||||
server: check_go_env buildjs ##@Development Start the backend in development mode
|
server: check_go_env buildjs ##@Development Start the backend in development mode
|
||||||
go tool reflex -d none -c reflex.conf
|
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
|
||||||
.PHONY: server
|
.PHONY: server
|
||||||
|
|
||||||
stop: ##@Development Stop development servers (UI and backend)
|
stop: ##@Development Stop development servers (UI and backend)
|
||||||
|
|
@ -50,23 +42,19 @@ stop: ##@Development Stop development servers (UI and backend)
|
||||||
.PHONY: stop
|
.PHONY: stop
|
||||||
|
|
||||||
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
|
||||||
go tool ginkgo watch -tags=$(GO_BUILD_TAGS) -notify ./...
|
go tool ginkgo watch -tags=netgo -notify ./...
|
||||||
.PHONY: watch
|
.PHONY: watch
|
||||||
|
|
||||||
PKG ?= ./...
|
PKG ?= ./...
|
||||||
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
test: ##@Development Run Go tests. Use PKG variable to specify packages to test, e.g. make test PKG=./server
|
||||||
go test -tags $(GO_BUILD_TAGS) $(PKG)
|
go test -tags netgo $(PKG)
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
||||||
test-ndpgen: ##@Development Run tests for ndpgen plugin
|
testall: test-race test-i18n test-js ##@Development Run Go and JS tests
|
||||||
cd plugins/cmd/ndpgen && go test ./......
|
|
||||||
.PHONY: test-ndpgen
|
|
||||||
|
|
||||||
testall: test test-ndpgen test-i18n test-js ##@Development Run Go and JS tests
|
|
||||||
.PHONY: testall
|
.PHONY: testall
|
||||||
|
|
||||||
test-race: ##@Development Run Go tests with race detector
|
test-race: ##@Development Run Go tests with race detector
|
||||||
go test -tags $(GO_BUILD_TAGS) -race -shuffle=on $(PKG)
|
go test -tags netgo -race -shuffle=on $(PKG)
|
||||||
.PHONY: test-race
|
.PHONY: test-race
|
||||||
|
|
||||||
test-js: ##@Development Run JS tests
|
test-js: ##@Development Run JS tests
|
||||||
|
|
@ -79,8 +67,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 +84,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 -v --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lintall: lint ##@Development Lint Go and JS code
|
lintall: lint ##@Development Lint Go and JS code
|
||||||
|
|
@ -151,19 +100,9 @@ format: ##@Development Format code
|
||||||
.PHONY: format
|
.PHONY: format
|
||||||
|
|
||||||
wire: check_go_env ##@Development Update Dependency Injection
|
wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags=netgo ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
|
||||||
go generate ./...
|
|
||||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
|
||||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
|
||||||
cd plugins/cmd/ndpgen && go run . -input=../../host -output=../../pdk -go -rust -shared=../../types
|
|
||||||
cd plugins/cmd/ndpgen && go run . -capability-only -input=../../capabilities -output=../../pdk -go -rust -shared=../../types
|
|
||||||
cd plugins/cmd/ndpgen && go run . -schemas -input=../../capabilities -shared=../../types
|
|
||||||
go mod tidy -C plugins/pdk/go
|
|
||||||
.PHONY: gen
|
|
||||||
|
|
||||||
snapshots: ##@Development Update (GoLang) Snapshot tests
|
snapshots: ##@Development Update (GoLang) Snapshot tests
|
||||||
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
UPDATE_SNAPSHOTS=true go tool ginkgo ./server/subsonic/responses/...
|
||||||
.PHONY: snapshots
|
.PHONY: snapshots
|
||||||
|
|
@ -188,14 +127,14 @@ setup-git: ##@Development Setup Git hooks (pre-commit and pre-push)
|
||||||
.PHONY: setup-git
|
.PHONY: setup-git
|
||||||
|
|
||||||
build: check_go_env buildjs ##@Build Build the project
|
build: check_go_env buildjs ##@Build Build the project
|
||||||
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
go build -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
|
|
||||||
buildall: deprecated build
|
buildall: deprecated build
|
||||||
.PHONY: buildall
|
.PHONY: buildall
|
||||||
|
|
||||||
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
debug-build: check_go_env buildjs ##@Build Build the project (with remote debug on)
|
||||||
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=$(GO_BUILD_TAGS)
|
go build -gcflags="all=-N -l" -ldflags="-X github.com/navidrome/navidrome/consts.gitSha=$(GIT_SHA) -X github.com/navidrome/navidrome/consts.gitTag=$(GIT_TAG)" -tags=netgo
|
||||||
.PHONY: debug-build
|
.PHONY: debug-build
|
||||||
|
|
||||||
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
buildjs: check_node_env ui/build/index.html ##@Build Build only frontend
|
||||||
|
|
@ -220,6 +159,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 +171,7 @@ docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidro
|
||||||
--platform $(IMAGE_PLATFORMS) \
|
--platform $(IMAGE_PLATFORMS) \
|
||||||
--build-arg GIT_TAG=${GIT_TAG} \
|
--build-arg GIT_TAG=${GIT_TAG} \
|
||||||
--build-arg GIT_SHA=${GIT_SHA} \
|
--build-arg GIT_SHA=${GIT_SHA} \
|
||||||
|
--build-arg CROSS_TAGLIB_VERSION=${CROSS_TAGLIB_VERSION} \
|
||||||
--tag $(DOCKER_TAG) .
|
--tag $(DOCKER_TAG) .
|
||||||
.PHONY: docker-image
|
.PHONY: docker-image
|
||||||
|
|
||||||
|
|
@ -243,8 +184,8 @@ docker-msi: ##@Cross_Compilation Build MSI installer for Windows
|
||||||
@du -h binaries/msi/*.msi
|
@du -h binaries/msi/*.msi
|
||||||
.PHONY: docker-msi
|
.PHONY: docker-msi
|
||||||
|
|
||||||
docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run tag=<tag>
|
run-docker: ##@Development Run a Navidrome Docker image. Usage: make run-docker tag=<tag>
|
||||||
@if [ -z "$(tag)" ]; then echo "Usage: make docker-run tag=<tag>"; exit 1; fi
|
@if [ -z "$(tag)" ]; then echo "Usage: make run-docker tag=<tag>"; exit 1; fi
|
||||||
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
@TAG_DIR="tmp/$$(echo '$(tag)' | tr '/:' '_')"; mkdir -p "$$TAG_DIR"; \
|
||||||
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
VOLUMES="-v $(PWD)/$$TAG_DIR:/data"; \
|
||||||
if [ -f navidrome.toml ]; then \
|
if [ -f navidrome.toml ]; then \
|
||||||
|
|
@ -255,7 +196,7 @@ docker-run: ##@Development Run a Navidrome Docker image. Usage: make docker-run
|
||||||
fi; \
|
fi; \
|
||||||
fi; \
|
fi; \
|
||||||
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
echo "Running: docker run --rm -p 4533:4533 $$VOLUMES $(tag)"; docker run --rm -p 4533:4533 $$VOLUMES $(tag)
|
||||||
.PHONY: docker-run
|
.PHONY: run-docker
|
||||||
|
|
||||||
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
package: docker-build ##@Cross_Compilation Create binaries and packages for ALL supported platforms
|
||||||
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
@if [ -z `which goreleaser` ]; then echo "Please install goreleaser first: https://goreleaser.com/install/"; exit 1; fi
|
||||||
|
|
@ -274,39 +215,6 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc
|
||||||
.PHONY: get-music
|
.PHONY: get-music
|
||||||
|
|
||||||
|
|
||||||
##########################################
|
|
||||||
#### Worktrees
|
|
||||||
|
|
||||||
WORKTREES_DIR := .worktrees
|
|
||||||
|
|
||||||
wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1]
|
|
||||||
@if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi
|
|
||||||
@mkdir -p $(WORKTREES_DIR)
|
|
||||||
@echo "Creating worktree for branch '${name}'..."
|
|
||||||
@git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \
|
|
||||||
git worktree add $(WORKTREES_DIR)/${name} ${name}
|
|
||||||
@if [ -n "${go}" ]; then \
|
|
||||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \
|
|
||||||
else \
|
|
||||||
./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \
|
|
||||||
fi
|
|
||||||
@echo "\nWorktree ready at $(WORKTREES_DIR)/${name}"
|
|
||||||
@echo " cd $(WORKTREES_DIR)/${name}"
|
|
||||||
.PHONY: wt
|
|
||||||
|
|
||||||
rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name
|
|
||||||
@if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi
|
|
||||||
@if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi
|
|
||||||
@echo "Removing worktree '${name}'..."
|
|
||||||
@git worktree remove --force $(WORKTREES_DIR)/${name}
|
|
||||||
@echo "Worktree '${name}' removed."
|
|
||||||
@echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}"
|
|
||||||
.PHONY: rm-wt
|
|
||||||
|
|
||||||
ls-wt: ##@Worktrees List all active git worktrees
|
|
||||||
@git worktree list
|
|
||||||
.PHONY: ls-wt
|
|
||||||
|
|
||||||
##########################################
|
##########################################
|
||||||
#### Miscellaneous
|
#### Miscellaneous
|
||||||
|
|
||||||
|
|
@ -358,6 +266,24 @@ deprecated:
|
||||||
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
@echo "WARNING: This target is deprecated and will be removed in future releases. Use 'make build' instead."
|
||||||
.PHONY: deprecated
|
.PHONY: deprecated
|
||||||
|
|
||||||
|
# Generate Go code from plugins/api/api.proto
|
||||||
|
plugin-gen: check_go_env ##@Development Generate Go code from plugins protobuf files
|
||||||
|
go generate ./plugins/...
|
||||||
|
.PHONY: plugin-gen
|
||||||
|
|
||||||
|
plugin-examples: check_go_env ##@Development Build all example plugins
|
||||||
|
$(MAKE) -C plugins/examples clean all
|
||||||
|
.PHONY: plugin-examples
|
||||||
|
|
||||||
|
plugin-clean: check_go_env ##@Development Clean all plugins
|
||||||
|
$(MAKE) -C plugins/examples clean
|
||||||
|
$(MAKE) -C plugins/testdata clean
|
||||||
|
.PHONY: plugin-clean
|
||||||
|
|
||||||
|
plugin-tests: check_go_env ##@Development Build all test plugins
|
||||||
|
$(MAKE) -C plugins/testdata clean all
|
||||||
|
.PHONY: plugin-tests
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
HELP_FUN = \
|
HELP_FUN = \
|
||||||
|
|
|
||||||
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,296 +0,0 @@
|
||||||
package deezer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("deezerAgent", func() {
|
|
||||||
var ctx context.Context
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = context.Background()
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.Deezer.Enabled = true
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("deezerConstructor", func() {
|
|
||||||
It("uses configured languages", func() {
|
|
||||||
conf.Server.Deezer.Languages = []string{"pt", "en"}
|
|
||||||
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
|
|
||||||
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("searchArtist", func() {
|
|
||||||
var agent *deezerAgent
|
|
||||||
var httpClient *fakeHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &fakeHttpClient{}
|
|
||||||
agent = &deezerAgent{
|
|
||||||
dataStore: &tests.MockDataStore{},
|
|
||||||
client: newClient(httpClient),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("picks the exact-name match with the most fans when several share the name", func() {
|
|
||||||
// Deezer RANKING order returns a low-popularity homonym first (see issue #5802)
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":61045802,"name":"Queen","nb_fan":75},
|
|
||||||
{"id":141954732,"name":"Queen","nb_fan":397},
|
|
||||||
{"id":135041032,"name":"Queen(Ares)","nb_fan":133},
|
|
||||||
{"id":183179807,"name":"Queen","nb_fan":53},
|
|
||||||
{"id":412,"name":"Queen","nb_fan":12744378}
|
|
||||||
],"total":5}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
artist, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(artist.ID).To(Equal(412))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("matches the name case-insensitively", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":1,"name":"QUEEN","nb_fan":10},
|
|
||||||
{"id":2,"name":"queen","nb_fan":20}
|
|
||||||
],"total":2}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
artist, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(artist.ID).To(Equal(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
// The artwork worker settles an artist as "no image" on agents.ErrNotFound, so a throttled
|
|
||||||
// lookup reaching that here would record a permanent absence.
|
|
||||||
It("surfaces an exhausted quota instead of reporting the artist as not found", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(
|
|
||||||
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err).ToNot(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when no result matches the name exactly", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":1,"name":"Queens of the Stone Age","nb_fan":100}
|
|
||||||
],"total":1}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
_, err := agent.searchArtist(ctx, "Queen")
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistImages", func() {
|
|
||||||
var agent *deezerAgent
|
|
||||||
var httpClient *fakeHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &fakeHttpClient{}
|
|
||||||
agent = &deezerAgent{
|
|
||||||
dataStore: &tests.MockDataStore{},
|
|
||||||
client: newClient(httpClient),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the real images when the artist has a picture", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
|
||||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist/abc/1000x1000-000000-80-0-0.jpg",
|
|
||||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist/abc/500x500-000000-80-0-0.jpg"}
|
|
||||||
],"total":1}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(images).To(HaveLen(2))
|
|
||||||
Expect(images[0].URL).To(ContainSubstring("1000x1000"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when the artist only has empty-id placeholder pictures", func() {
|
|
||||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[
|
|
||||||
{"id":412,"name":"Queen","nb_fan":12744378,
|
|
||||||
"picture_xl":"https://cdn-images.dzcdn.net/images/artist//1000x1000-000000-80-0-0.jpg",
|
|
||||||
"picture_big":"https://cdn-images.dzcdn.net/images/artist//500x500-000000-80-0-0.jpg",
|
|
||||||
"picture_medium":"https://cdn-images.dzcdn.net/images/artist//250x250-000000-80-0-0.jpg",
|
|
||||||
"picture_small":"https://cdn-images.dzcdn.net/images/artist//56x56-000000-80-0-0.jpg"}
|
|
||||||
],"total":1}`)),
|
|
||||||
})
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "", "Queen", "")
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(images).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistBiography - Language Fallback", func() {
|
|
||||||
var agent *deezerAgent
|
|
||||||
var httpClient *langAwareHttpClient
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = newLangAwareHttpClient()
|
|
||||||
|
|
||||||
// Mock search artist (returns Michael Jackson)
|
|
||||||
fSearch, _ := os.Open("tests/fixtures/deezer.search.artist.json")
|
|
||||||
httpClient.searchResponse = &http.Response{Body: fSearch, StatusCode: 200}
|
|
||||||
|
|
||||||
// Mock JWT token
|
|
||||||
testJWT := createTestJWT(5 * time.Minute)
|
|
||||||
httpClient.jwtResponse = &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
setupAgent := func(languages []string) {
|
|
||||||
conf.Server.Deezer.Languages = languages
|
|
||||||
agent = &deezerAgent{
|
|
||||||
dataStore: &tests.MockDataStore{},
|
|
||||||
client: newClient(httpClient),
|
|
||||||
languages: languages,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
It("returns content in first language when available (1 bio API call)", func() {
|
|
||||||
setupAgent([]string{"fr", "en"})
|
|
||||||
|
|
||||||
// French biography available
|
|
||||||
fFr, _ := os.Open("tests/fixtures/deezer.artist.bio.fr.json")
|
|
||||||
httpClient.bioResponses["fr"] = &http.Response{Body: fFr, StatusCode: 200}
|
|
||||||
|
|
||||||
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(bio).To(ContainSubstring("Guy-Manuel de Homem Christo et Thomas Bangalter"))
|
|
||||||
Expect(httpClient.bioRequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("fr"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to second language when first returns empty (2 bio API calls)", func() {
|
|
||||||
setupAgent([]string{"ja", "en"})
|
|
||||||
|
|
||||||
// Japanese returns empty biography
|
|
||||||
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
|
||||||
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
// English returns full biography
|
|
||||||
fEn, _ := os.Open("tests/fixtures/deezer.artist.bio.en.json")
|
|
||||||
httpClient.bioResponses["en"] = &http.Response{Body: fEn, StatusCode: 200}
|
|
||||||
|
|
||||||
bio, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
|
|
||||||
Expect(httpClient.bioRequestCount).To(Equal(2))
|
|
||||||
Expect(httpClient.bioRequests[0].Header.Get("Accept-Language")).To(Equal("ja"))
|
|
||||||
Expect(httpClient.bioRequests[1].Header.Get("Accept-Language")).To(Equal("en"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotFound when all languages return empty", func() {
|
|
||||||
setupAgent([]string{"ja", "xx"})
|
|
||||||
|
|
||||||
// Both languages return empty biography
|
|
||||||
fJa, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
|
||||||
httpClient.bioResponses["ja"] = &http.Response{Body: fJa, StatusCode: 200}
|
|
||||||
fXx, _ := os.Open("tests/fixtures/deezer.artist.bio.empty.json")
|
|
||||||
httpClient.bioResponses["xx"] = &http.Response{Body: fXx, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := agent.GetArtistBiography(ctx, "", "Michael Jackson", "")
|
|
||||||
|
|
||||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
|
||||||
Expect(httpClient.bioRequestCount).To(Equal(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// langAwareHttpClient is a mock HTTP client that returns different responses based on the Accept-Language header
|
|
||||||
type langAwareHttpClient struct {
|
|
||||||
searchResponse *http.Response
|
|
||||||
jwtResponse *http.Response
|
|
||||||
bioResponses map[string]*http.Response
|
|
||||||
bioRequests []*http.Request
|
|
||||||
bioRequestCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newLangAwareHttpClient() *langAwareHttpClient {
|
|
||||||
return &langAwareHttpClient{
|
|
||||||
bioResponses: make(map[string]*http.Response),
|
|
||||||
bioRequests: make([]*http.Request, 0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *langAwareHttpClient) Do(req *http.Request) (*http.Response, error) {
|
|
||||||
// Handle search artist request
|
|
||||||
if req.URL.Host == "api.deezer.com" && req.URL.Path == "/search/artist" {
|
|
||||||
if c.searchResponse != nil {
|
|
||||||
return c.searchResponse, nil
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle JWT token request
|
|
||||||
if req.URL.Host == "auth.deezer.com" && req.URL.Path == "/login/anonymous" {
|
|
||||||
if c.jwtResponse != nil {
|
|
||||||
return c.jwtResponse, nil
|
|
||||||
}
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 500,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":"no mock"}`)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle bio request (GraphQL API)
|
|
||||||
if req.URL.Host == "pipe.deezer.com" && req.URL.Path == "/api" {
|
|
||||||
c.bioRequestCount++
|
|
||||||
c.bioRequests = append(c.bioRequests, req)
|
|
||||||
lang := req.Header.Get("Accept-Language")
|
|
||||||
if resp, ok := c.bioResponses[lang]; ok {
|
|
||||||
return resp, nil
|
|
||||||
}
|
|
||||||
// Return empty bio by default
|
|
||||||
return &http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"data":{"artist":{"bio":{"full":""}}}}`)),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
panic("URL not mocked: " + req.URL.String())
|
|
||||||
}
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
// Package gotaglib provides an alternative metadata extractor using go-taglib,
|
|
||||||
// a pure Go (WASM-based) implementation of TagLib.
|
|
||||||
//
|
|
||||||
// This extractor aims for parity with the CGO-based taglib extractor. It uses
|
|
||||||
// TagLib's PropertyMap interface for standard tags. The File handle API provides
|
|
||||||
// efficient access to format-specific tags (ID3v2 frames, MP4 atoms, ASF attributes)
|
|
||||||
// through a single file open operation.
|
|
||||||
//
|
|
||||||
// This extractor is registered under the name "taglib". It only works with a filesystem
|
|
||||||
// (fs.FS) and does not support direct local file paths. Files returned by the filesystem
|
|
||||||
// must implement io.ReadSeeker for go-taglib to read them.
|
|
||||||
package gotaglib
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"io/fs"
|
|
||||||
"runtime/debug"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/storage/local"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
|
||||||
"go.senan.xyz/taglib"
|
|
||||||
)
|
|
||||||
|
|
||||||
type extractor struct {
|
|
||||||
fs fs.FS
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) Parse(files ...string) (map[string]metadata.Info, error) {
|
|
||||||
results := make(map[string]metadata.Info)
|
|
||||||
for _, path := range files {
|
|
||||||
props, err := e.extractMetadata(path)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
results[path] = *props
|
|
||||||
}
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) Version() string {
|
|
||||||
bi, ok := debug.ReadBuildInfo()
|
|
||||||
if ok {
|
|
||||||
for _, dep := range bi.Deps {
|
|
||||||
if dep.Path == "go.senan.xyz/taglib" {
|
|
||||||
if dep.Replace != nil {
|
|
||||||
return dep.Replace.Version
|
|
||||||
}
|
|
||||||
return dep.Version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) {
|
|
||||||
// Recover from panics in the WASM runtime that can occur during any taglib
|
|
||||||
// operation (opening, reading tags, or reading properties). This catches crashes
|
|
||||||
// from malformed files or WASM runtime issues (e.g., wazero mmap failures on
|
|
||||||
// hardened systems with MemoryDenyWriteExecute=true).
|
|
||||||
debug.SetPanicOnFault(true)
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r)
|
|
||||||
debug.PrintStack()
|
|
||||||
err = fmt.Errorf("WASM runtime panic: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
f, close, err := e.openFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer close()
|
|
||||||
|
|
||||||
// Get all tags and properties in one go
|
|
||||||
allTags := f.AllTags()
|
|
||||||
props := f.Properties()
|
|
||||||
|
|
||||||
// Map properties to AudioProperties
|
|
||||||
ap := metadata.AudioProperties{
|
|
||||||
Duration: props.Length.Round(time.Millisecond * 10),
|
|
||||||
BitRate: int(props.Bitrate),
|
|
||||||
Channels: int(props.Channels),
|
|
||||||
SampleRate: int(props.SampleRate),
|
|
||||||
BitDepth: int(props.BitsPerSample),
|
|
||||||
Codec: props.Codec,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert normalized tags to lowercase keys (go-taglib returns UPPERCASE keys)
|
|
||||||
normalizedTags := make(map[string][]string, len(allTags.Tags))
|
|
||||||
for key, values := range allTags.Tags {
|
|
||||||
lowerKey := strings.ToLower(key)
|
|
||||||
normalizedTags[lowerKey] = values
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process format-specific raw tags
|
|
||||||
processRawTags(allTags, normalizedTags)
|
|
||||||
|
|
||||||
// Parse track/disc totals from "N/Total" format
|
|
||||||
parseTuple(normalizedTags, "track")
|
|
||||||
parseTuple(normalizedTags, "disc")
|
|
||||||
|
|
||||||
// Adjust some ID3 tags
|
|
||||||
parseLyrics(normalizedTags)
|
|
||||||
parseTIPL(normalizedTags)
|
|
||||||
delete(normalizedTags, "tmcl") // TMCL is already parsed by TagLib
|
|
||||||
|
|
||||||
// Determine if file has embedded picture
|
|
||||||
hasPicture := len(props.Images) > 0
|
|
||||||
|
|
||||||
return &metadata.Info{
|
|
||||||
Tags: normalizedTags,
|
|
||||||
AudioProperties: ap,
|
|
||||||
HasPicture: hasPicture,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openFile opens the file at filePath using the extractor's filesystem.
|
|
||||||
// It returns a TagLib File handle and a cleanup function to close resources.
|
|
||||||
func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) {
|
|
||||||
// Open the file from the filesystem
|
|
||||||
file, err := e.fs.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
rs, isSeekable := file.(io.ReadSeeker)
|
|
||||||
if !isSeekable {
|
|
||||||
file.Close()
|
|
||||||
return nil, nil, errors.New("file is not seekable")
|
|
||||||
}
|
|
||||||
// WithFilename provides a format detection hint via the file extension,
|
|
||||||
// since OpenStream alone relies on content-sniffing which fails for some files.
|
|
||||||
f, err = taglib.OpenStream(rs,
|
|
||||||
taglib.WithReadStyle(taglib.ReadStyleFast),
|
|
||||||
taglib.WithFilename(filePath),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
file.Close()
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
closeFunc = func() {
|
|
||||||
f.Close()
|
|
||||||
file.Close()
|
|
||||||
}
|
|
||||||
return f, closeFunc, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTuple parses track/disc numbers in "N/Total" format and separates them.
|
|
||||||
// For example, tracknumber="2/10" becomes tracknumber="2" and tracktotal="10".
|
|
||||||
func parseTuple(tags map[string][]string, prop string) {
|
|
||||||
tagName := prop + "number"
|
|
||||||
tagTotal := prop + "total"
|
|
||||||
if value, ok := tags[tagName]; ok && len(value) > 0 {
|
|
||||||
parts := strings.Split(value[0], "/")
|
|
||||||
tags[tagName] = []string{parts[0]}
|
|
||||||
if len(parts) == 2 {
|
|
||||||
tags[tagTotal] = []string{parts[1]}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLyrics ensures lyrics tags have a language code.
|
|
||||||
// If lyrics exist without a language code, they are moved to "lyrics:xxx".
|
|
||||||
func parseLyrics(tags map[string][]string) {
|
|
||||||
lyrics := tags["lyrics"]
|
|
||||||
if len(lyrics) > 0 {
|
|
||||||
tags["lyrics:xxx"] = lyrics
|
|
||||||
delete(tags, "lyrics")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// processRawTags processes format-specific raw tags based on the detected file format.
|
|
||||||
// This handles ID3v2 frames (MP3/WAV/AIFF), MP4 atoms, and ASF attributes.
|
|
||||||
func processRawTags(allTags taglib.AllTags, normalizedTags map[string][]string) {
|
|
||||||
switch allTags.Format {
|
|
||||||
case taglib.FormatMPEG, taglib.FormatWAV, taglib.FormatAIFF:
|
|
||||||
parseID3v2Frames(allTags.Raw, normalizedTags)
|
|
||||||
case taglib.FormatMP4:
|
|
||||||
parseMP4Atoms(allTags.Raw, normalizedTags)
|
|
||||||
case taglib.FormatASF:
|
|
||||||
parseASFAttributes(allTags.Raw, normalizedTags)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseID3v2Frames processes ID3v2 raw frames to extract USLT/SYLT with language codes.
|
|
||||||
// This extracts language-specific lyrics that the standard Tags() doesn't provide.
|
|
||||||
func parseID3v2Frames(rawFrames map[string][]string, tags map[string][]string) {
|
|
||||||
// Process frames that have language-specific data
|
|
||||||
for key, values := range rawFrames {
|
|
||||||
lowerKey := strings.ToLower(key)
|
|
||||||
|
|
||||||
// Handle USLT:xxx and SYLT:xxx (lyrics with language codes)
|
|
||||||
if strings.HasPrefix(lowerKey, "uslt:") || strings.HasPrefix(lowerKey, "sylt:") {
|
|
||||||
parts := strings.SplitN(lowerKey, ":", 2)
|
|
||||||
if len(parts) == 2 && parts[1] != "" {
|
|
||||||
lang := parts[1]
|
|
||||||
lyricsKey := "lyrics:" + lang
|
|
||||||
tags[lyricsKey] = append(tags[lyricsKey], values...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we found any language-specific lyrics from ID3v2 frames, remove the generic lyrics
|
|
||||||
for key := range tags {
|
|
||||||
if strings.HasPrefix(key, "lyrics:") && key != "lyrics" {
|
|
||||||
delete(tags, "lyrics")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const iTunesKeyPrefix = "----:com.apple.iTunes:"
|
|
||||||
|
|
||||||
// parseMP4Atoms processes MP4 raw atoms to get iTunes-specific tags.
|
|
||||||
func parseMP4Atoms(rawAtoms map[string][]string, tags map[string][]string) {
|
|
||||||
// Process all atoms and add them to tags
|
|
||||||
for key, values := range rawAtoms {
|
|
||||||
// Strip iTunes prefix and convert to lowercase
|
|
||||||
normalizedKey := strings.TrimPrefix(key, iTunesKeyPrefix)
|
|
||||||
normalizedKey = strings.ToLower(normalizedKey)
|
|
||||||
|
|
||||||
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
|
||||||
if _, exists := tags[normalizedKey]; !exists {
|
|
||||||
tags[normalizedKey] = values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseASFAttributes processes ASF raw attributes to get WMA-specific tags.
|
|
||||||
func parseASFAttributes(rawAttrs map[string][]string, tags map[string][]string) {
|
|
||||||
// Process all attributes and add them to tags
|
|
||||||
for key, values := range rawAttrs {
|
|
||||||
normalizedKey := strings.ToLower(key)
|
|
||||||
|
|
||||||
// Only add if the tag doesn't already exist (avoid duplication with PropertyMap)
|
|
||||||
if _, exists := tags[normalizedKey]; !exists {
|
|
||||||
tags[normalizedKey] = values
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These are the only roles we support, based on Picard's tag map:
|
|
||||||
// https://picard-docs.musicbrainz.org/downloads/MusicBrainz_Picard_Tag_Map.html
|
|
||||||
var tiplMapping = map[string]string{
|
|
||||||
"arranger": "arranger",
|
|
||||||
"engineer": "engineer",
|
|
||||||
"producer": "producer",
|
|
||||||
"mix": "mixer",
|
|
||||||
"DJ-mix": "djmixer",
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
|
|
||||||
//
|
|
||||||
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
|
|
||||||
//
|
|
||||||
// and breaks it down into a map of roles and names, e.g.:
|
|
||||||
//
|
|
||||||
// {"arranger": ["Andrew Powell"], "engineer": ["Chris Blair", "Pat Stapley"], "producer": ["Eric Woolfson"]}.
|
|
||||||
func parseTIPL(tags map[string][]string) {
|
|
||||||
tipl := tags["tipl"]
|
|
||||||
if len(tipl) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
addRole := func(currentRole string, currentValue []string) {
|
|
||||||
if currentRole != "" && len(currentValue) > 0 {
|
|
||||||
role := tiplMapping[currentRole]
|
|
||||||
tags[role] = append(tags[role], strings.Join(currentValue, " "))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var currentRole string
|
|
||||||
var currentValue []string
|
|
||||||
for part := range strings.SplitSeq(tipl[0], " ") {
|
|
||||||
if _, ok := tiplMapping[part]; ok {
|
|
||||||
addRole(currentRole, currentValue)
|
|
||||||
currentRole = part
|
|
||||||
currentValue = nil
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
currentValue = append(currentValue, part)
|
|
||||||
}
|
|
||||||
addRole(currentRole, currentValue)
|
|
||||||
delete(tags, "tipl")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ local.Extractor = (*extractor)(nil)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
local.RegisterExtractor("taglib", func(fsys fs.FS, baseDir string) local.Extractor {
|
|
||||||
return &extractor{fsys}
|
|
||||||
})
|
|
||||||
conf.AddHook(func() {
|
|
||||||
log.Debug("go-taglib version", "version", extractor{}.Version())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,312 +0,0 @@
|
||||||
package lastfm
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/md5"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("client", func() {
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
var client *client
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client = newClient("API_KEY", "SECRET", httpClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("albumGetInfo", func() {
|
|
||||||
It("returns an album on successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
album, err := client.albumGetInfo(context.Background(), "Believe", "U2", "mbid-1234", "pt")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(album.Name).To(Equal("Believe"))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not double-encode plus signs", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("artistGetInfo", func() {
|
|
||||||
It("returns an artist for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
artist, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(artist.Name).To(Equal("U2"))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an http status != 200", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError("last.fm http status: (500)"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an http status != 200", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":3,"message":"Invalid Method - No method with that name in this package"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError(&lastFMError{Code: 3, Message: "Invalid Method - No method with that name in this package"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":6,"message":"The artist you supplied could not be found"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError(&lastFMError{Code: 6, Message: "The artist you supplied could not be found"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if HttpClient.Do() returns error", func() {
|
|
||||||
httpClient.Err = errors.New("generic error")
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError("generic error"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if returned body is not a valid JSON", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`<xml>NOT_VALID_JSON</xml>`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "U2", "pt")
|
|
||||||
Expect(err).To(MatchError("invalid character '<' looking for beginning of value"))
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("artistGetSimilar", func() {
|
|
||||||
It("returns an artist for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
similar, err := client.artistGetSimilar(context.Background(), "U2", 2)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(len(similar.Artists)).To(Equal(2))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("artistGetTopTracks", func() {
|
|
||||||
It("returns top tracks for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
top, err := client.artistGetTopTracks(context.Background(), "U2", 2)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(len(top.Track)).To(Equal(2))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("trackGetSimilar", func() {
|
|
||||||
It("returns similar tracks for a successful response", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
similar, err := client.trackGetSimilar(context.Background(), "Just Can't Get Enough", "Depeche Mode", 5)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(len(similar.Track)).To(Equal(5))
|
|
||||||
Expect(similar.Track[0].Name).To(Equal("Dreaming of Me"))
|
|
||||||
Expect(similar.Track[0].Artist.Name).To(Equal("Depeche Mode"))
|
|
||||||
Expect(similar.Track[0].Match).To(Equal(1.0))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("double-encodes plus signs in the track and artist names", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
query := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(query.Get("track")).To(Equal("1%2B1"))
|
|
||||||
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty list when no similar tracks found", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
similar, err := client.trackGetSimilar(context.Background(), "UnknownTrack", "UnknownArtist", 3)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
Expect(similar.Track).To(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetToken", func() {
|
|
||||||
It("returns a token when the request is successful", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"token":"TOKEN"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
Expect(client.GetToken(context.Background())).To(Equal("TOKEN"))
|
|
||||||
queryParams := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(queryParams.Get("method")).To(Equal("auth.getToken"))
|
|
||||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
|
||||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("getSession", func() {
|
|
||||||
It("returns a session key when the request is successful", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"SESSION_KEY","subscriber":0}}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
Expect(client.getSession(context.Background(), "TOKEN")).To(Equal("SESSION_KEY"))
|
|
||||||
queryParams := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(queryParams.Get("method")).To(Equal("auth.getSession"))
|
|
||||||
Expect(queryParams.Get("format")).To(Equal("json"))
|
|
||||||
Expect(queryParams.Get("token")).To(Equal("TOKEN"))
|
|
||||||
Expect(queryParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(queryParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("scrobble", func() {
|
|
||||||
It("sends parameters in request body for POST", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"scrobbles":{"scrobble":{"ignoredMessage":{"code":"0"}},"@attr":{"accepted":1}}}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ScrobbleInfo{
|
|
||||||
artist: "U2",
|
|
||||||
track: "One",
|
|
||||||
album: "Achtung Baby",
|
|
||||||
trackNumber: 1,
|
|
||||||
duration: 276,
|
|
||||||
albumArtist: "U2",
|
|
||||||
}
|
|
||||||
err := client.scrobble(context.Background(), "SESSION_KEY", info)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
|
|
||||||
req := httpClient.SavedRequest
|
|
||||||
Expect(req.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
|
|
||||||
Expect(req.URL.RawQuery).To(BeEmpty())
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(req.Body)
|
|
||||||
bodyParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(bodyParams.Get("method")).To(Equal("track.scrobble"))
|
|
||||||
Expect(bodyParams.Get("artist")).To(Equal("U2"))
|
|
||||||
Expect(bodyParams.Get("track")).To(Equal("One"))
|
|
||||||
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("updateNowPlaying", func() {
|
|
||||||
It("sends parameters in request body for POST", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"nowplaying":{"ignoredMessage":{"code":"0"}}}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
|
|
||||||
info := ScrobbleInfo{
|
|
||||||
artist: "U2",
|
|
||||||
track: "One",
|
|
||||||
album: "Achtung Baby",
|
|
||||||
trackNumber: 1,
|
|
||||||
duration: 276,
|
|
||||||
albumArtist: "U2",
|
|
||||||
}
|
|
||||||
err := client.updateNowPlaying(context.Background(), "SESSION_KEY", info)
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
|
|
||||||
req := httpClient.SavedRequest
|
|
||||||
Expect(req.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(req.Header.Get("Content-Type")).To(Equal("application/x-www-form-urlencoded"))
|
|
||||||
Expect(req.URL.RawQuery).To(BeEmpty())
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(req.Body)
|
|
||||||
bodyParams, _ := url.ParseQuery(string(body))
|
|
||||||
Expect(bodyParams.Get("method")).To(Equal("track.updateNowPlaying"))
|
|
||||||
Expect(bodyParams.Get("artist")).To(Equal("U2"))
|
|
||||||
Expect(bodyParams.Get("track")).To(Equal("One"))
|
|
||||||
Expect(bodyParams.Get("sk")).To(Equal("SESSION_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_key")).To(Equal("API_KEY"))
|
|
||||||
Expect(bodyParams.Get("api_sig")).ToNot(BeEmpty())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("sign", func() {
|
|
||||||
It("adds an api_sig param with the signature", func() {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Add("d", "444")
|
|
||||||
params.Add("callback", "https://myserver.com")
|
|
||||||
params.Add("a", "111")
|
|
||||||
params.Add("format", "json")
|
|
||||||
params.Add("c", "333")
|
|
||||||
params.Add("b", "222")
|
|
||||||
client.sign(params)
|
|
||||||
Expect(params).To(HaveKey("api_sig"))
|
|
||||||
sig := params.Get("api_sig")
|
|
||||||
expected := fmt.Sprintf("%x", md5.Sum([]byte("a111b222c333d444SECRET")))
|
|
||||||
Expect(sig).To(Equal(expected))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/utils/cache"
|
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
listenBrainzAgentName = "listenbrainz"
|
|
||||||
sessionKeyProperty = "ListenBrainzSessionKey"
|
|
||||||
)
|
|
||||||
|
|
||||||
type listenBrainzAgent struct {
|
|
||||||
ds model.DataStore
|
|
||||||
sessionKeys *agents.SessionKeys
|
|
||||||
baseURL string
|
|
||||||
client *client
|
|
||||||
}
|
|
||||||
|
|
||||||
func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
|
||||||
l := &listenBrainzAgent{
|
|
||||||
ds: ds,
|
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
|
||||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
|
||||||
}
|
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
|
||||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
|
||||||
l.client = newClient(l.baseURL, chc)
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) AgentName() string {
|
|
||||||
return listenBrainzAgentName
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) formatListen(track *model.MediaFile) listenInfo {
|
|
||||||
artistMBIDs := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
|
|
||||||
return p.MbzArtistID
|
|
||||||
})
|
|
||||||
artistNames := slice.Map(track.Participants[model.RoleArtist], func(p model.Participant) string {
|
|
||||||
return p.Name
|
|
||||||
})
|
|
||||||
li := listenInfo{
|
|
||||||
TrackMetadata: trackMetadata{
|
|
||||||
ArtistName: track.Artist,
|
|
||||||
TrackName: track.Title,
|
|
||||||
ReleaseName: track.Album,
|
|
||||||
AdditionalInfo: additionalInfo{
|
|
||||||
SubmissionClient: consts.AppName,
|
|
||||||
SubmissionClientVersion: consts.Version,
|
|
||||||
TrackNumber: track.TrackNumber,
|
|
||||||
ArtistNames: artistNames,
|
|
||||||
ArtistMBIDs: artistMBIDs,
|
|
||||||
RecordingMBID: track.MbzRecordingID,
|
|
||||||
ReleaseMBID: track.MbzAlbumID,
|
|
||||||
ReleaseGroupMBID: track.MbzReleaseGroupID,
|
|
||||||
DurationMs: int(track.Duration * 1000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return li
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
|
|
||||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
|
||||||
if err != nil || sk == "" {
|
|
||||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
li := l.formatListen(track)
|
|
||||||
err = l.client.updateNowPlaying(ctx, sk, li)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "ListenBrainz updateNowPlaying returned error", "track", track.Title, err)
|
|
||||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) Scrobble(ctx context.Context, userId string, s scrobbler.Scrobble) error {
|
|
||||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
|
||||||
if err != nil || sk == "" {
|
|
||||||
return errors.Join(err, scrobbler.ErrNotAuthorized)
|
|
||||||
}
|
|
||||||
|
|
||||||
li := l.formatListen(&s.MediaFile)
|
|
||||||
li.ListenedAt = int(s.TimeStamp.Unix())
|
|
||||||
err = l.client.scrobble(ctx, sk, li)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var lbErr *listenBrainzError
|
|
||||||
isListenBrainzError := errors.As(err, &lbErr)
|
|
||||||
if !isListenBrainzError {
|
|
||||||
log.Warn(ctx, "ListenBrainz Scrobble returned HTTP error", "track", s.Title, err)
|
|
||||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
|
||||||
}
|
|
||||||
if lbErr.Code == 500 || lbErr.Code == 503 {
|
|
||||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
|
||||||
}
|
|
||||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) IsAuthorized(ctx context.Context, userId string) bool {
|
|
||||||
sk, err := l.sessionKeys.Get(ctx, userId)
|
|
||||||
return err == nil && sk != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetArtistURL(ctx context.Context, id, name, mbid string) (string, error) {
|
|
||||||
if mbid == "" {
|
|
||||||
return "", agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
url, err := l.client.getArtistUrl(ctx, mbid)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return url, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]agents.Song, error) {
|
|
||||||
resp, err := l.client.getArtistTopSongs(ctx, mbid, count)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(resp) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
res := make([]agents.Song, len(resp))
|
|
||||||
for i, t := range resp {
|
|
||||||
res[i] = agents.Song{
|
|
||||||
Album: t.ReleaseName,
|
|
||||||
AlbumMBID: t.ReleaseMBID,
|
|
||||||
Artists: topSongArtists(t.ArtistName, t.ArtistMBIDs),
|
|
||||||
Duration: t.DurationMs,
|
|
||||||
Name: t.RecordingName,
|
|
||||||
MBID: t.RecordingMbid,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// topSongArtists maps the top-recordings response, which carries a single combined display name
|
|
||||||
// (e.g. "X feat. Y") plus a per-artist MBID list, onto agents.Artist. Names and MBIDs are not
|
|
||||||
// positionally pairable, so the display name attaches to the first credit and any further MBIDs
|
|
||||||
// become MBID-only collaborators — still valid identity signals for the matcher.
|
|
||||||
func topSongArtists(name string, mbids []string) []agents.Artist {
|
|
||||||
if len(mbids) == 0 {
|
|
||||||
if name == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return []agents.Artist{{Name: name}}
|
|
||||||
}
|
|
||||||
artists := make([]agents.Artist, len(mbids))
|
|
||||||
artists[0] = agents.Artist{Name: name, MBID: mbids[0]}
|
|
||||||
for i, m := range mbids[1:] {
|
|
||||||
artists[i+1] = agents.Artist{MBID: m}
|
|
||||||
}
|
|
||||||
return artists
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetSimilarArtists(ctx context.Context, id string, name string, mbid string, limit int) ([]agents.Artist, error) {
|
|
||||||
if mbid == "" {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := l.client.getSimilarArtists(ctx, mbid, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
artists := make([]agents.Artist, len(resp))
|
|
||||||
for i, artist := range resp {
|
|
||||||
artists[i] = agents.Artist{
|
|
||||||
MBID: artist.MBID,
|
|
||||||
Name: artist.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return artists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id string, name string, artist string, mbid string, limit int) ([]agents.Song, error) {
|
|
||||||
if mbid == "" {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := l.client.getSimilarRecordings(ctx, mbid, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(resp) == 0 {
|
|
||||||
return nil, agents.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
songs := make([]agents.Song, len(resp))
|
|
||||||
for i, song := range resp {
|
|
||||||
songs[i] = agents.Song{
|
|
||||||
Album: song.ReleaseName,
|
|
||||||
AlbumMBID: song.ReleaseMBID,
|
|
||||||
Artists: []agents.Artist{{Name: song.Artist}},
|
|
||||||
MBID: song.MBID,
|
|
||||||
Name: song.Name,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return songs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
conf.AddHook(func() {
|
|
||||||
if conf.Server.ListenBrainz.Enabled {
|
|
||||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
|
||||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
|
||||||
// See https://go.dev/doc/faq#nil_error
|
|
||||||
a := listenBrainzConstructor(ds)
|
|
||||||
if a != nil {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
agents.Register(listenBrainzAgentName, func(ds model.DataStore) agents.Interface {
|
|
||||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
|
||||||
// See https://go.dev/doc/faq#nil_error
|
|
||||||
a := listenBrainzConstructor(ds)
|
|
||||||
if a != nil {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ agents.ArtistTopSongsRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
_ agents.ArtistURLRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
_ agents.ArtistSimilarRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
_ agents.SimilarSongsByTrackRetriever = (*listenBrainzAgent)(nil)
|
|
||||||
)
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
. "github.com/onsi/gomega/gstruct"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("listenBrainzAgent", func() {
|
|
||||||
var ds model.DataStore
|
|
||||||
var ctx context.Context
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
var track *model.MediaFile
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
ds = &tests.MockDataStore{}
|
|
||||||
ctx = context.Background()
|
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = newClient("http://localhost:8080", httpClient)
|
|
||||||
track = &model.MediaFile{
|
|
||||||
ID: "123",
|
|
||||||
Title: "Track Title",
|
|
||||||
Album: "Track Album",
|
|
||||||
Artist: "Track Artist",
|
|
||||||
TrackNumber: 1,
|
|
||||||
MbzRecordingID: "mbz-123",
|
|
||||||
MbzAlbumID: "mbz-456",
|
|
||||||
MbzReleaseGroupID: "mbz-789",
|
|
||||||
Duration: 142.2,
|
|
||||||
Participants: map[model.Role]model.ParticipantList{
|
|
||||||
model.RoleArtist: []model.Participant{
|
|
||||||
{Artist: model.Artist{ID: "ar-1", Name: "Artist 1", MbzArtistID: "mbz-111"}},
|
|
||||||
{Artist: model.Artist{ID: "ar-2", Name: "Artist 2", MbzArtistID: "mbz-222"}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("formatListen", func() {
|
|
||||||
It("constructs the listenInfo properly", func() {
|
|
||||||
lr := agent.formatListen(track)
|
|
||||||
Expect(lr).To(MatchAllFields(Fields{
|
|
||||||
"ListenedAt": Equal(0),
|
|
||||||
"TrackMetadata": MatchAllFields(Fields{
|
|
||||||
"ArtistName": Equal(track.Artist),
|
|
||||||
"TrackName": Equal(track.Title),
|
|
||||||
"ReleaseName": Equal(track.Album),
|
|
||||||
"AdditionalInfo": MatchAllFields(Fields{
|
|
||||||
"SubmissionClient": Equal(consts.AppName),
|
|
||||||
"SubmissionClientVersion": Equal(consts.Version),
|
|
||||||
"TrackNumber": Equal(track.TrackNumber),
|
|
||||||
"RecordingMBID": Equal(track.MbzRecordingID),
|
|
||||||
"ReleaseMBID": Equal(track.MbzAlbumID),
|
|
||||||
"ReleaseGroupMBID": Equal(track.MbzReleaseGroupID),
|
|
||||||
"ArtistNames": ConsistOf("Artist 1", "Artist 2"),
|
|
||||||
"ArtistMBIDs": ConsistOf("mbz-111", "mbz-222"),
|
|
||||||
"DurationMs": Equal(142200),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("NowPlaying", func() {
|
|
||||||
It("updates NowPlaying successfully", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.NowPlaying(ctx, "user-1", track, 0)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
||||||
err := agent.NowPlaying(ctx, "user-2", track, 0)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Scrobble", func() {
|
|
||||||
var sc scrobbler.Scrobble
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
sc = scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sends a Scrobble successfully", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sets the Timestamp properly", func() {
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)), StatusCode: 200}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
decoder := json.NewDecoder(httpClient.SavedRequest.Body)
|
|
||||||
var lr listenBrainzRequestBody
|
|
||||||
err = decoder.Decode(&lr)
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(lr.Payload[0].ListenedAt).To(Equal(int(sc.TimeStamp.Unix())))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrNotAuthorized if user is not linked", func() {
|
|
||||||
err := agent.Scrobble(ctx, "user-2", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 503", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 503, "error": "Cannot submit listens to queue, please try again later."}`)),
|
|
||||||
StatusCode: 503,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on error 500", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 500, "error": "Something went wrong. Please try again."}`)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrRetryLater on http errors", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad Gateway`)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns ErrUnrecoverable on other errors", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "error": "BadRequest: Invalid JSON document submitted."}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
|
|
||||||
err := agent.Scrobble(ctx, "user-1", sc)
|
|
||||||
Expect(err).To(MatchError(scrobbler.ErrUnrecoverable))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps a 429 scrobble for retry and carries the delay", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
|
||||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetArtistUrl", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns artist url when MBID present", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
Expect(agent.GetArtistURL(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")).To(Equal("http://projectmili.com/"))
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when url not present", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls fails", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when ListenBrainz returns an error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetArtistURL(ctx, "", "", "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist_mbids")).To(Equal("7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetTopSongs", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error on listenbrainz error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetArtistTopSongs(ctx, "", "", "1", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.Path).To(Equal("/1/popularity/top-recordings-for-artist/1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns all tracks when asked", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "world.execute(me);",
|
|
||||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
|
||||||
Album: "Miracle Milk",
|
|
||||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
Duration: 211912,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "String Theocracy",
|
|
||||||
MBID: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
|
||||||
Album: "String Theocracy",
|
|
||||||
AlbumMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
|
||||||
Duration: 174000,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns only one track when prompted", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "world.execute(me);",
|
|
||||||
MBID: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
Artists: []agents.Artist{{Name: "Mili", MBID: "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"}},
|
|
||||||
Album: "Miracle Milk",
|
|
||||||
AlbumMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
Duration: 211912,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("maps a multi-artist top song to one named artist plus MBID-only collaborators", func() {
|
|
||||||
body := `[{
|
|
||||||
"recording_name": "Collab",
|
|
||||||
"recording_mbid": "rec-1",
|
|
||||||
"artist_name": "Drake feat. Future",
|
|
||||||
"artist_mbids": ["mbid-drake", "mbid-future"],
|
|
||||||
"release_name": "Album",
|
|
||||||
"release_mbid": "rel-1",
|
|
||||||
"length": 200000
|
|
||||||
}]`
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "mbid-drake", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(HaveLen(1))
|
|
||||||
Expect(data[0].Artists).To(Equal([]agents.Artist{
|
|
||||||
{Name: "Drake feat. Future", MBID: "mbid-drake"},
|
|
||||||
{MBID: "mbid-future"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves Artists nil when the top song carries no name or MBIDs", func() {
|
|
||||||
body := `[{"recording_name": "Anon", "recording_mbid": "rec-1", "artist_name": "", "artist_mbids": []}]`
|
|
||||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(body)), StatusCode: 200}
|
|
||||||
data, err := agent.GetArtistTopSongs(ctx, "", "", "x", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(HaveLen(1))
|
|
||||||
Expect(data[0].Artists).To(BeNil())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetSimilarArtists", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
baseUrl := "https://labs.api.listenbrainz.org/similar-artists/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&artist_mbids="
|
|
||||||
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetSimilarArtists(ctx, "", "", mbid, 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error on listenbrainz error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetSimilarArtists(ctx, "", "", "1", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns all data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
|
|
||||||
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns subset of data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarArtists(ctx, "", "", "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson"},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("GetSimilarTracks", func() {
|
|
||||||
var agent *listenBrainzAgent
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
|
|
||||||
baseUrl := "https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30&recording_mbids="
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client := newClient("BASE_URL", httpClient)
|
|
||||||
agent = listenBrainzConstructor(ds)
|
|
||||||
agent.client = client
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error when fetch calls", func() {
|
|
||||||
httpClient.Err = errors.New("error")
|
|
||||||
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an error on listenbrainz error", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", "1", 1)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns all data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "Take On Me",
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
ISRC: "",
|
|
||||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
|
||||||
Album: "Hunting High and Low",
|
|
||||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Duration: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "Wake Me Up Before You Go‐Go",
|
|
||||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
|
||||||
ISRC: "",
|
|
||||||
Artists: []agents.Artist{{Name: "Wham!"}},
|
|
||||||
Album: "Make It Big",
|
|
||||||
AlbumMBID: "c143d542-48dc-446b-b523-1762da721638",
|
|
||||||
Duration: 0,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns subset of data on call", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
resp, err := agent.GetSimilarSongsByTrack(ctx, "", "", "", mbid, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.RequestCount).To(Equal(1))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + mbid))
|
|
||||||
Expect(resp).To(Equal([]agents.Song{
|
|
||||||
{
|
|
||||||
ID: "",
|
|
||||||
Name: "Take On Me",
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
ISRC: "",
|
|
||||||
Artists: []agents.Artist{{Name: "a‐ha"}},
|
|
||||||
Album: "Hunting High and Low",
|
|
||||||
AlbumMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Duration: 0,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,395 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"cmp"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
lbzApiUrl = "https://api.listenbrainz.org/1/"
|
|
||||||
labsBase = "https://labs.api.listenbrainz.org/"
|
|
||||||
)
|
|
||||||
|
|
||||||
// retryLaterErr reads the wait ListenBrainz asked for. It sends X-RateLimit-Reset-In
|
|
||||||
// (delta-seconds) on every response, including the 429, and never Retry-After.
|
|
||||||
func retryLaterErr(h http.Header) *agents.RetryLaterError {
|
|
||||||
return &agents.RetryLaterError{RetryIn: agents.ParseRetryIn(h.Get("X-RateLimit-Reset-In"))}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrorNotFound = errors.New("listenbrainz: not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
type listenBrainzError struct {
|
|
||||||
Code int
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *listenBrainzError) Error() string {
|
|
||||||
return fmt.Sprintf("ListenBrainz error(%d): %s", e.Code, e.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
type httpDoer interface {
|
|
||||||
Do(req *http.Request) (*http.Response, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newClient(baseURL string, hc httpDoer) *client {
|
|
||||||
return &client{baseURL, hc}
|
|
||||||
}
|
|
||||||
|
|
||||||
type client struct {
|
|
||||||
baseURL string
|
|
||||||
hc httpDoer
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenBrainzResponse struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Valid bool `json:"valid"`
|
|
||||||
UserName string `json:"user_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenBrainzRequest struct {
|
|
||||||
ApiKey string //nolint:gosec
|
|
||||||
Body listenBrainzRequestBody
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenBrainzRequestBody struct {
|
|
||||||
ListenType listenType `json:"listen_type,omitempty"`
|
|
||||||
Payload []listenInfo `json:"payload,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type listenType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
Single listenType = "single"
|
|
||||||
PlayingNow listenType = "playing_now"
|
|
||||||
)
|
|
||||||
|
|
||||||
type listenInfo struct {
|
|
||||||
ListenedAt int `json:"listened_at,omitempty"`
|
|
||||||
TrackMetadata trackMetadata `json:"track_metadata"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type trackMetadata struct {
|
|
||||||
ArtistName string `json:"artist_name,omitempty"`
|
|
||||||
TrackName string `json:"track_name,omitempty"`
|
|
||||||
ReleaseName string `json:"release_name,omitempty"`
|
|
||||||
AdditionalInfo additionalInfo `json:"additional_info"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type additionalInfo struct {
|
|
||||||
SubmissionClient string `json:"submission_client,omitempty"`
|
|
||||||
SubmissionClientVersion string `json:"submission_client_version,omitempty"`
|
|
||||||
TrackNumber int `json:"tracknumber,omitempty"`
|
|
||||||
ArtistNames []string `json:"artist_names,omitempty"`
|
|
||||||
ArtistMBIDs []string `json:"artist_mbids,omitempty"`
|
|
||||||
RecordingMBID string `json:"recording_mbid,omitempty"`
|
|
||||||
ReleaseMBID string `json:"release_mbid,omitempty"`
|
|
||||||
ReleaseGroupMBID string `json:"release_group_mbid,omitempty"`
|
|
||||||
DurationMs int `json:"duration_ms,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) validateToken(ctx context.Context, apiKey string) (*listenBrainzResponse, error) {
|
|
||||||
r := &listenBrainzRequest{
|
|
||||||
ApiKey: apiKey,
|
|
||||||
}
|
|
||||||
response, err := c.makeAuthenticatedRequest(ctx, http.MethodGet, "validate-token", r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) updateNowPlaying(ctx context.Context, apiKey string, li listenInfo) error {
|
|
||||||
r := &listenBrainzRequest{
|
|
||||||
ApiKey: apiKey,
|
|
||||||
Body: listenBrainzRequestBody{
|
|
||||||
ListenType: PlayingNow,
|
|
||||||
Payload: []listenInfo{li},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp.Status != "ok" {
|
|
||||||
log.Warn(ctx, "ListenBrainz: NowPlaying was not accepted", "status", resp.Status)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) scrobble(ctx context.Context, apiKey string, li listenInfo) error {
|
|
||||||
r := &listenBrainzRequest{
|
|
||||||
ApiKey: apiKey,
|
|
||||||
Body: listenBrainzRequestBody{
|
|
||||||
ListenType: Single,
|
|
||||||
Payload: []listenInfo{li},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
resp, err := c.makeAuthenticatedRequest(ctx, http.MethodPost, "submit-listens", r)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if resp.Status != "ok" {
|
|
||||||
log.Warn(ctx, "ListenBrainz: Scrobble was not accepted", "status", resp.Status)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) path(endpoint string) (string, error) {
|
|
||||||
u, err := url.Parse(c.baseURL)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
u.Path = path.Join(u.Path, endpoint)
|
|
||||||
return u.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, endpoint string, r *listenBrainzRequest) (*listenBrainzResponse, error) {
|
|
||||||
b, _ := json.Marshal(r.Body)
|
|
||||||
uri, err := c.path(endpoint)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, method, uri, bytes.NewBuffer(b))
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
|
|
||||||
if r.ApiKey != "" {
|
|
||||||
req.Header.Add("Authorization", fmt.Sprintf("Token %s", r.ApiKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
return nil, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var response listenBrainzResponse
|
|
||||||
jsonErr := decoder.Decode(&response)
|
|
||||||
if resp.StatusCode != 200 && jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, jsonErr
|
|
||||||
}
|
|
||||||
if response.Code != 0 && response.Code != 200 {
|
|
||||||
// LB also reports rate limiting as a body code, not only as an HTTP status.
|
|
||||||
if response.Code == http.StatusTooManyRequests {
|
|
||||||
return &response, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
return &response, &listenBrainzError{Code: response.Code, Message: response.Error}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type lbzHttpError struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint string, params url.Values) (*http.Response, error) {
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, method, lbzApiUrl+endpoint, nil)
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
req.URL.RawQuery = params.Encode()
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// On a 200 code, there is no code. Decode using using error message if it exists
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode == http.StatusTooManyRequests {
|
|
||||||
return nil, retryLaterErr(resp.Header)
|
|
||||||
}
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var lbzError lbzHttpError
|
|
||||||
jsonErr := decoder.Decode(&lbzError)
|
|
||||||
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, &listenBrainzError{Code: lbzError.Code, Message: lbzError.Error}
|
|
||||||
}
|
|
||||||
|
|
||||||
return resp, err
|
|
||||||
}
|
|
||||||
|
|
||||||
type artistMetadataResult struct {
|
|
||||||
Rels struct {
|
|
||||||
OfficialHomepage string `json:"official homepage,omitempty"`
|
|
||||||
} `json:"rels,omitzero"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getArtistUrl(ctx context.Context, mbid string) (string, error) {
|
|
||||||
params := url.Values{}
|
|
||||||
params.Add("artist_mbids", mbid)
|
|
||||||
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "metadata/artist", params)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var response []artistMetadataResult
|
|
||||||
jsonErr := decoder.Decode(&response)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return "", fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(response) == 0 || response[0].Rels.OfficialHomepage == "" {
|
|
||||||
return "", ErrorNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
return response[0].Rels.OfficialHomepage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type trackInfo struct {
|
|
||||||
ArtistName string `json:"artist_name"`
|
|
||||||
ArtistMBIDs []string `json:"artist_mbids"`
|
|
||||||
DurationMs uint32 `json:"length"`
|
|
||||||
RecordingName string `json:"recording_name"`
|
|
||||||
RecordingMbid string `json:"recording_mbid"`
|
|
||||||
ReleaseName string `json:"release_name"`
|
|
||||||
ReleaseMBID string `json:"release_mbid"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getArtistTopSongs(ctx context.Context, mbid string, count int) ([]trackInfo, error) {
|
|
||||||
resp, err := c.makeGenericRequest(ctx, http.MethodGet, "popularity/top-recordings-for-artist/"+mbid, url.Values{})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var response []trackInfo
|
|
||||||
jsonErr := decoder.Decode(&response)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(response) > count {
|
|
||||||
return response[0:count], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type artist struct {
|
|
||||||
MBID string `json:"artist_mbid"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Score int `json:"score"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int) ([]artist, error) {
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
req.URL.RawQuery = url.Values{
|
|
||||||
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
|
|
||||||
}.Encode()
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var artists []artist
|
|
||||||
jsonErr := decoder.Decode(&artists)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(artists) > limit {
|
|
||||||
return artists[:limit], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return artists, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type recording struct {
|
|
||||||
MBID string `json:"recording_mbid"`
|
|
||||||
Name string `json:"recording_name"`
|
|
||||||
Artist string `json:"artist_credit_name"`
|
|
||||||
ReleaseName string `json:"release_name"`
|
|
||||||
ReleaseMBID string `json:"release_mbid"`
|
|
||||||
Score int `json:"score"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit int) ([]recording, error) {
|
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
|
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
|
||||||
req.URL.RawQuery = url.Values{
|
|
||||||
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
|
|
||||||
}.Encode()
|
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
|
||||||
resp, err := c.hc.Do(req)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
defer resp.Body.Close()
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
|
||||||
|
|
||||||
var recordings []recording
|
|
||||||
jsonErr := decoder.Decode(&recordings)
|
|
||||||
if jsonErr != nil {
|
|
||||||
return nil, fmt.Errorf("ListenBrainz: HTTP Error, Status: (%d)", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
// For whatever reason, labs API isn't guaranteed to give results in the proper order
|
|
||||||
// and may also provide duplicates. See listenbrainz.labs.similar-recordings-real-out-of-order.json
|
|
||||||
// generated from https://labs.api.listenbrainz.org/similar-recordings/json?recording_mbids=8f3471b5-7e6a-48da-86a9-c1c07a0f47ae&algorithm=session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30
|
|
||||||
slices.SortFunc(recordings, func(a, b recording) int {
|
|
||||||
return cmp.Or(
|
|
||||||
cmp.Compare(b.Score, a.Score), // Sort by score descending
|
|
||||||
cmp.Compare(a.MBID, b.MBID), // Then by MBID ascending to ensure deterministic order for duplicates
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
recordings = slices.CompactFunc(recordings, func(a, b recording) bool {
|
|
||||||
return a.MBID == b.MBID
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(recordings) > limit {
|
|
||||||
return recordings[:limit], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return recordings, nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,537 +0,0 @@
|
||||||
package listenbrainz
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("client", func() {
|
|
||||||
var httpClient *tests.FakeHttpClient
|
|
||||||
var client *client
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient = &tests.FakeHttpClient{}
|
|
||||||
client = newClient("BASE_URL/", httpClient)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("listenBrainzResponse", func() {
|
|
||||||
It("parses a response properly", func() {
|
|
||||||
var response listenBrainzResponse
|
|
||||||
err := json.Unmarshal([]byte(`{"code": 200, "message": "Message", "user_name": "UserName", "valid": true, "status": "ok", "error": "Error"}`), &response)
|
|
||||||
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(response.Code).To(Equal(200))
|
|
||||||
Expect(response.Message).To(Equal("Message"))
|
|
||||||
Expect(response.UserName).To(Equal("UserName"))
|
|
||||||
Expect(response.Valid).To(BeTrue())
|
|
||||||
Expect(response.Status).To(Equal("ok"))
|
|
||||||
Expect(response.Error).To(Equal("Error"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("validateToken", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 200, "message": "Token valid.", "user_name": "ListenBrainzUser", "valid": true}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("formats the request properly", func() {
|
|
||||||
_, err := client.validateToken(context.Background(), "LB-TOKEN")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/validate-token"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses and returns the response", func() {
|
|
||||||
res, err := client.validateToken(context.Background(), "LB-TOKEN")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(res.Valid).To(Equal(true))
|
|
||||||
Expect(res.UserName).To(Equal("ListenBrainzUser"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("with listenInfo", func() {
|
|
||||||
var li listenInfo
|
|
||||||
BeforeEach(func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"status": "ok"}`)),
|
|
||||||
StatusCode: 200,
|
|
||||||
}
|
|
||||||
li = listenInfo{
|
|
||||||
TrackMetadata: trackMetadata{
|
|
||||||
ArtistName: "Track Artist",
|
|
||||||
TrackName: "Track Title",
|
|
||||||
ReleaseName: "Track Album",
|
|
||||||
AdditionalInfo: additionalInfo{
|
|
||||||
TrackNumber: 1,
|
|
||||||
ArtistNames: []string{"Artist 1", "Artist 2"},
|
|
||||||
ArtistMBIDs: []string{"mbz-789", "mbz-012"},
|
|
||||||
RecordingMBID: "mbz-123",
|
|
||||||
ReleaseMBID: "mbz-456",
|
|
||||||
DurationMs: 142200,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("updateNowPlaying", func() {
|
|
||||||
It("formats the request properly", func() {
|
|
||||||
Expect(client.updateNowPlaying(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.nowplaying.request.json")
|
|
||||||
Expect(body).To(MatchJSON(f))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("scrobble", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
li.ListenedAt = 1635000000
|
|
||||||
})
|
|
||||||
|
|
||||||
It("formats the request properly", func() {
|
|
||||||
Expect(client.scrobble(context.Background(), "LB-TOKEN", li)).To(Succeed())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodPost))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal("BASE_URL/submit-listens"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Authorization")).To(Equal("Token LB-TOKEN"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
|
|
||||||
body, _ := io.ReadAll(httpClient.SavedRequest.Body)
|
|
||||||
f, _ := os.ReadFile("tests/fixtures/listenbrainz.scrobble.request.json")
|
|
||||||
Expect(body).To(MatchJSON(f))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getArtistUrl", func() {
|
|
||||||
baseUrl := "https://api.listenbrainz.org/1/metadata/artist?"
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code": 400,"error": "artist mbid 1 is not valid."}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "1")
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist mbid 1 is not valid."))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles a malformed request without meaningful body", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(``)),
|
|
||||||
StatusCode: 501,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "1")
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (501)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns not found when the artist has no official homepage", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.no_homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "7c2cc610-f998-43ef-a08f-dae3344b8973")
|
|
||||||
Expect(err.Error()).To(Equal("listenbrainz: not found"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=7c2cc610-f998-43ef-a08f-dae3344b8973"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns data when the artist has a homepage", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.artist.metadata.homepage.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
url, err := client.getArtistUrl(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(url).To(Equal("http://projectmili.com/"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "artist_mbids=d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getArtistTopSongs", func() {
|
|
||||||
baseUrl := "https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/"
|
|
||||||
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`{"code":400,"error":"artist_mbid: '1' is not a valid uuid"}`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistTopSongs(context.Background(), "1", 50)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz error(400): artist_mbid: '1' is not a valid uuid"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles a malformed request without standard body", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(``)),
|
|
||||||
StatusCode: 500,
|
|
||||||
}
|
|
||||||
_, err := client.getArtistTopSongs(context.Background(), "1", 1)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (500)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "1"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns all tracks when given the opportunity", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 5)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]trackInfo{
|
|
||||||
{
|
|
||||||
ArtistName: "Mili",
|
|
||||||
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
|
|
||||||
DurationMs: 211912,
|
|
||||||
RecordingName: "world.execute(me);",
|
|
||||||
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
ReleaseName: "Miracle Milk",
|
|
||||||
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ArtistName: "Mili",
|
|
||||||
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
|
|
||||||
DurationMs: 174000,
|
|
||||||
RecordingName: "String Theocracy",
|
|
||||||
RecordingMbid: "afa2c83d-b17f-4029-b9da-790ea9250cf9",
|
|
||||||
ReleaseName: "String Theocracy",
|
|
||||||
ReleaseMBID: "d79a38e3-7016-4f39-a31a-f495ce914b8e",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("It returns a subset of tracks when allowed", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.popularity.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
data, err := client.getArtistTopSongs(context.Background(), "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(data).To(Equal([]trackInfo{
|
|
||||||
{
|
|
||||||
ArtistName: "Mili",
|
|
||||||
ArtistMBIDs: []string{"d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"},
|
|
||||||
DurationMs: 211912,
|
|
||||||
RecordingName: "world.execute(me);",
|
|
||||||
RecordingMbid: "9980309d-3480-4e7e-89ce-fce971a452be",
|
|
||||||
ReleaseName: "Miracle Milk",
|
|
||||||
ReleaseMBID: "38a8f6e1-0e34-4418-a89d-78240a367408",
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(baseUrl + "d2a92ee2-27ce-4e71-bfc5-12e34fe8ef56"))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getSimilarArtists", func() {
|
|
||||||
var algorithm string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
|
||||||
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-artists/json?algorithm=%s&artist_mbids=%s", algorithm, mbid)
|
|
||||||
}
|
|
||||||
|
|
||||||
mbid := "db92a151-1ac2-438b-bc43-b82e149ddd50"
|
|
||||||
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getSimilarArtists(context.Background(), "1", 2)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles real data properly", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
|
|
||||||
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("truncates data when requested", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarArtists(context.Background(), "db92a151-1ac2-438b-bc43-b82e149ddd50", 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fetches a different endpoint when algorithm changes", func() {
|
|
||||||
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
|
||||||
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
|
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarArtists(context.Background(), mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]artist{
|
|
||||||
{MBID: "f27ec8db-af05-4f36-916e-3d57f91ecf5e", Name: "Michael Jackson", Score: 800},
|
|
||||||
{MBID: "7364dea6-ca9a-48e3-be01-b44ad0d19897", Name: "a-ha", Score: 792},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("getSimilarRecordings", func() {
|
|
||||||
var algorithm string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
|
||||||
DeferCleanup(configtest.SetupConfig())
|
|
||||||
})
|
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
|
||||||
return fmt.Sprintf("https://labs.api.listenbrainz.org/similar-recordings/json?algorithm=%s&recording_mbids=%s", algorithm, mbid)
|
|
||||||
}
|
|
||||||
|
|
||||||
mbid := "8f3471b5-7e6a-48da-86a9-c1c07a0f47ae"
|
|
||||||
|
|
||||||
It("handles a malformed request with status code", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Bad request`)),
|
|
||||||
StatusCode: 400,
|
|
||||||
}
|
|
||||||
_, err := client.getSimilarRecordings(context.Background(), "1", 2)
|
|
||||||
Expect(err.Error()).To(Equal("ListenBrainz: HTTP Error, Status: (400)"))
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl("1")))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles real data properly", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
|
||||||
Name: "Wake Me Up Before You Go‐Go",
|
|
||||||
Artist: "Wham!",
|
|
||||||
ReleaseName: "Make It Big",
|
|
||||||
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
|
|
||||||
Score: 65,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("truncates data when requested", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("properly sorts by score and truncates duplicates", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings-real-out-of-order.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
// There are actually 5 items. The dedup should happen FIRST
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 4)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "e4b347be-ecb2-44ff-aaa8-3d4c517d7ea5",
|
|
||||||
Name: "Everybody Wants to Rule the World",
|
|
||||||
Artist: "Tears for Fears",
|
|
||||||
ReleaseName: "Songs From the Big Chair",
|
|
||||||
ReleaseMBID: "21f19b06-81f1-347a-add5-5d0c77696597",
|
|
||||||
Score: 68,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "80033c72-aa19-4ba8-9227-afb075fec46e",
|
|
||||||
Name: "Wake Me Up Before You Go‐Go",
|
|
||||||
Artist: "Wham!",
|
|
||||||
ReleaseName: "Make It Big",
|
|
||||||
ReleaseMBID: "c143d542-48dc-446b-b523-1762da721638",
|
|
||||||
Score: 65,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
MBID: "ef4c6855-949e-4e22-b41e-8e0a2d372d5f",
|
|
||||||
Name: "Tainted Love",
|
|
||||||
Artist: "Soft Cell",
|
|
||||||
ReleaseName: "Non-Stop Erotic Cabaret",
|
|
||||||
ReleaseMBID: "1acaa870-6e0c-4b6e-9e91-fdec4e5ea4b1",
|
|
||||||
Score: 61,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("uses a different algorithm when configured", func() {
|
|
||||||
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
|
||||||
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
|
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
resp, err := client.getSimilarRecordings(context.Background(), mbid, 1)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.Method).To(Equal(http.MethodGet))
|
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(getUrl(mbid)))
|
|
||||||
Expect(httpClient.SavedRequest.Header.Get("Content-Type")).To(Equal("application/json; charset=UTF-8"))
|
|
||||||
Expect(resp).To(Equal([]recording{
|
|
||||||
{
|
|
||||||
MBID: "12f65dca-de8f-43fe-a65d-f12a02aaadf3",
|
|
||||||
Name: "Take On Me",
|
|
||||||
Artist: "a‐ha",
|
|
||||||
ReleaseName: "Hunting High and Low",
|
|
||||||
ReleaseMBID: "4ec07fe8-e7c6-3106-a0aa-fdf92f13f7fc",
|
|
||||||
Score: 124,
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("rate limiting", func() {
|
|
||||||
It("returns RetryLaterError with the header delay on 429", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(3 * time.Second))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns RetryLaterError with zero delay when no header is present", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(retry.RetryIn).To(BeZero())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("caps absurd header values at one hour",
|
|
||||||
func(header string) {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(retry.RetryIn).To(Equal(time.Hour))
|
|
||||||
},
|
|
||||||
Entry("a large value", "999999"),
|
|
||||||
Entry("a huge value", "99999999999"),
|
|
||||||
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
|
|
||||||
Entry("a value that overflows int64 nanoseconds", "18446744074"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("maps a body-level 429 sent with a non-429 status", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 200,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
|
||||||
}
|
|
||||||
_, err := client.validateToken(context.Background(), "token")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
|
|
||||||
httpClient.Res = http.Response{
|
|
||||||
StatusCode: 429,
|
|
||||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
|
|
||||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
|
||||||
}
|
|
||||||
_, err := client.getArtistUrl(context.Background(), "1")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
|
||||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(retry.RetryIn).To(Equal(5 * time.Second))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package gotaglib
|
package taglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
@ -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,11 +91,12 @@ 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() {
|
||||||
e = &extractor{fs: os.DirFS(".")}
|
e = &extractor{}
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("ReplayGain", func() {
|
Describe("ReplayGain", 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,
|
||||||
|
|
@ -149,7 +151,11 @@ var _ = Describe("Extractor", func() {
|
||||||
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
unsSylt := makeLyrics("xxx", "unspecified SYLT")
|
||||||
unsUslt := makeLyrics("xxx", "unspecified")
|
unsUslt := makeLyrics("xxx", "unspecified")
|
||||||
|
|
||||||
Expect(lyrics).To(ConsistOf(engSylt, engUslt, unsSylt, unsUslt))
|
// Why is the order inconsistent between runs? Nobody knows
|
||||||
|
Expect(lyrics).To(Or(
|
||||||
|
Equal(model.LyricList{engSylt, engUslt, unsSylt, unsUslt}),
|
||||||
|
Equal(model.LyricList{unsSylt, unsUslt, engSylt, engUslt}),
|
||||||
|
))
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
DescribeTable("format-specific lyrics", func(file string, isId3 bool) {
|
||||||
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("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 gotaglib
|
package taglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGoTagLib(t *testing.T) {
|
func TestTagLib(t *testing.T) {
|
||||||
tests.Init(t, true)
|
tests.Init(t, true)
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "GoTagLib Suite")
|
RunSpecs(t, "TagLib Suite")
|
||||||
}
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
package gotaglib
|
package taglib
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"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"
|
||||||
|
|
@ -15,7 +14,7 @@ var _ = Describe("Extractor", func() {
|
||||||
var e *extractor
|
var e *extractor
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
e = &extractor{fs: os.DirFS(".")}
|
e = &extractor{}
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Parse", func() {
|
Describe("Parse", func() {
|
||||||
|
|
@ -81,11 +80,12 @@ var _ = Describe("Extractor", func() {
|
||||||
Expect(err).To(BeNil())
|
Expect(err).To(BeNil())
|
||||||
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
Expect(m.Tags).To(HaveKeyWithValue("fbpm", []string{"141.7"}))
|
||||||
|
|
||||||
// TagLib 1.12 returns 18, previous versions return 39.
|
// TabLib 1.12 returns 18, previous versions return 39.
|
||||||
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
// See https://github.com/taglib/taglib/commit/2f238921824741b2cfe6fbfbfc9701d9827ab06b
|
||||||
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
Expect(m.AudioProperties.BitRate).To(BeElementOf(18, 19, 39, 40, 43, 49))
|
||||||
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
Expect(m.AudioProperties.Channels).To(BeElementOf(2))
|
||||||
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
|
Expect(m.AudioProperties.SampleRate).To(BeElementOf(8000))
|
||||||
Expect(m.HasPicture).To(BeTrue())
|
Expect(m.HasPicture).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -106,7 +106,7 @@ var _ = Describe("Extractor", func() {
|
||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
HaveKeyWithValue("replaygain_album_gain", []string{albumGain}),
|
||||||
HaveKeyWithValue("----:com.apple.itunes:replaygain_album_gain", []string{albumGain}),
|
HaveKeyWithValue("----:com.apple.itunes:replaygain_track_gain", []string{albumGain}),
|
||||||
))
|
))
|
||||||
|
|
||||||
Expect(m.Tags).To(Or(
|
Expect(m.Tags).To(Or(
|
||||||
|
|
@ -128,17 +128,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(
|
||||||
|
|
@ -185,9 +174,6 @@ var _ = Describe("Extractor", func() {
|
||||||
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 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),
|
Entry("correctly parses ogg (vorbis) tags", "test.ogg", "1.04s", 2, 8000, 0, "+7.64 dB", "0.11772506", "+7.64 dB", "0.11772506", false, true),
|
||||||
|
|
||||||
// ffmpeg -f lavfi -i "sine=frequency=1100:duration=1" -c:a libopus test.opus (tags added via mutagen)
|
|
||||||
Entry("correctly parses opus tags (#4998)", "test.opus", "1s", 1, 48000, 0, "+5.12 dB", "0.11345678", "+5.12 dB", "0.11345678", false, true),
|
|
||||||
|
|
||||||
// ffmpeg -f lavfi -i "sine=frequency=900:duration=1" test.wma
|
// 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
|
// 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),
|
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),
|
||||||
|
|
@ -214,9 +200,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
|
|
||||||
e = &extractor{fs: os.DirFS("/")}
|
|
||||||
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
accessForbiddenFile = utils.TempFileName("access_forbidden-", ".mp3")
|
||||||
|
|
||||||
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
f, err := os.OpenFile(accessForbiddenFile, os.O_WRONLY|os.O_CREATE, 0222)
|
||||||
|
|
@ -229,25 +212,20 @@ var _ = Describe("Extractor", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("correctly handle unreadable file due to insufficient read permission", func() {
|
It("correctly handle unreadable file due to insufficient read permission", func() {
|
||||||
// Strip leading slash for DirFS rooted at "/"
|
_, err := e.extractMetadata(accessForbiddenFile)
|
||||||
_, err := e.extractMetadata(accessForbiddenFile[1:])
|
|
||||||
Expect(err).To(MatchError(os.ErrPermission))
|
Expect(err).To(MatchError(os.ErrPermission))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("skips the file if it cannot be read", func() {
|
It("skips the file if it cannot be read", func() {
|
||||||
// Get current working directory to construct paths relative to root
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
// Strip leading slash for DirFS rooted at "/"
|
|
||||||
files := []string{
|
files := []string{
|
||||||
cwd[1:] + "/tests/fixtures/test.mp3",
|
"tests/fixtures/test.mp3",
|
||||||
cwd[1:] + "/tests/fixtures/test.ogg",
|
"tests/fixtures/test.ogg",
|
||||||
accessForbiddenFile[1:],
|
accessForbiddenFile,
|
||||||
}
|
}
|
||||||
mds, err := e.Parse(files...)
|
mds, err := e.Parse(files...)
|
||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(mds).To(HaveLen(2))
|
Expect(mds).To(HaveLen(2))
|
||||||
Expect(mds).ToNot(HaveKey(accessForbiddenFile[1:]))
|
Expect(mds).ToNot(HaveKey(accessForbiddenFile))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
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 {
|
||||||
log.Warn("Prune cancelled")
|
fmt.Println("Warning: pruning ALL backups")
|
||||||
return
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
|
log.Warn("Prune cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
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 _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
log.Warn("Restore cancelled")
|
log.Fatal("No existing database", "path", path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
|
log.Warn("Restore cancelled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
237
cmd/pls.go
237
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")
|
if err != nil {
|
||||||
|
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := os.Stat(outputFile)
|
|
||||||
if err != nil || !info.IsDir() {
|
|
||||||
log.Fatal("Output path must be an existing directory", "path", outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
if playlistID != "" {
|
|
||||||
pls := findPlaylist(ctx, ds, playlistID)
|
|
||||||
filename := str.SanitizeFilename(pls.Name) + ".m3u"
|
|
||||||
path := filepath.Join(outputFile, filename)
|
|
||||||
err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error writing playlist", "file", path, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allPls := fetchPlaylists(ctx, ds, "name")
|
|
||||||
|
|
||||||
nameCounts := make(map[string]int)
|
|
||||||
for _, pls := range allPls {
|
|
||||||
nameCounts[str.SanitizeFilename(pls.Name)]++
|
|
||||||
}
|
|
||||||
|
|
||||||
exported := 0
|
|
||||||
for _, pls := range allPls {
|
|
||||||
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sanitized := str.SanitizeFilename(pls.Name)
|
|
||||||
filename := sanitized + ".m3u"
|
|
||||||
if nameCounts[sanitized] > 1 {
|
|
||||||
shortID := pls.ID
|
|
||||||
if len(shortID) > 6 {
|
|
||||||
shortID = shortID[:6]
|
|
||||||
}
|
|
||||||
filename = sanitized + "_" + shortID + ".m3u"
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(outputFile, filename)
|
|
||||||
err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600)
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Error writing playlist", "file", path, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path)
|
|
||||||
exported++
|
|
||||||
}
|
|
||||||
fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runList(ctx context.Context) {
|
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),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
1153
cmd/plugin.go
1153
cmd/plugin.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,360 +1,193 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"io"
|
||||||
"encoding/json"
|
"os"
|
||||||
"errors"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var samplePlugins = model.Plugins{
|
var _ = Describe("Plugin CLI Commands", func() {
|
||||||
{ID: "alpha", Manifest: `{"name":"Alpha","version":"1.0.0","author":"me"}`, Enabled: true},
|
var tempDir string
|
||||||
{ID: "beta", Manifest: `{"name":"Beta","version":"2.1.0","author":"me"}`, Enabled: false, LastError: "boom"},
|
var cmd *cobra.Command
|
||||||
}
|
var stdOut *os.File
|
||||||
|
var origStdout *os.File
|
||||||
|
var outReader *os.File
|
||||||
|
|
||||||
var _ = Describe("plugin command format flags", func() {
|
// Helper to create a test plugin with the given name and details
|
||||||
// Regression: list and info must not share a format variable. Binding the
|
createTestPlugin := func(name, author, version string, capabilities []string) string {
|
||||||
// same var on both commands makes the last init() registration clobber the
|
pluginDir := filepath.Join(tempDir, name)
|
||||||
// other's default, breaking `plugin list` with no -f flag.
|
Expect(os.MkdirAll(pluginDir, 0755)).To(Succeed())
|
||||||
It("defaults `list -f` to table", func() {
|
|
||||||
Expect(pluginListCmd.Flags().Lookup("format").DefValue).To(Equal("table"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("defaults `info -f` to text", func() {
|
// Create a properly formatted capabilities JSON array
|
||||||
Expect(pluginInfoCmd.Flags().Lookup("format").DefValue).To(Equal("text"))
|
capabilitiesJSON := `"` + strings.Join(capabilities, `", "`) + `"`
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("formatPluginList", func() {
|
manifest := `{
|
||||||
It("renders csv with a header and one row per plugin", func() {
|
"name": "` + name + `",
|
||||||
out, err := formatPluginList(samplePlugins, "csv")
|
"author": "` + author + `",
|
||||||
Expect(err).ToNot(HaveOccurred())
|
"version": "` + version + `",
|
||||||
lines := strings.Split(strings.TrimSpace(out), "\n")
|
"description": "Plugin for testing",
|
||||||
Expect(lines).To(HaveLen(3)) // header + 2 rows
|
"website": "https://test.navidrome.org/` + name + `",
|
||||||
Expect(lines[0]).To(ContainSubstring("id"))
|
"capabilities": [` + capabilitiesJSON + `],
|
||||||
Expect(out).To(ContainSubstring("alpha"))
|
"permissions": {}
|
||||||
Expect(out).To(ContainSubstring("beta"))
|
}`
|
||||||
})
|
|
||||||
|
|
||||||
It("renders valid json", func() {
|
Expect(os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||||
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() {
|
// Create a dummy WASM file
|
||||||
out, err := formatPluginList(samplePlugins, "table")
|
wasmContent := []byte("dummy wasm content for testing")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(os.WriteFile(filepath.Join(pluginDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||||
Expect(out).To(ContainSubstring("Alpha"))
|
|
||||||
Expect(out).To(ContainSubstring("1.0.0"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("errors on an unknown format", func() {
|
return pluginDir
|
||||||
_, err := formatPluginList(samplePlugins, "yaml")
|
}
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("enable/disable plugin", func() {
|
// Helper to execute a command and return captured output
|
||||||
It("calls EnablePlugin on the manager", func() {
|
captureOutput := func(reader io.Reader) string {
|
||||||
mgr := &tests.MockPluginManager{}
|
stdOut.Close()
|
||||||
err := enablePlugin(context.Background(), mgr, "alpha")
|
outputBytes, err := io.ReadAll(reader)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
Expect(mgr.EnablePluginCalls).To(Equal([]string{"alpha"}))
|
return string(outputBytes)
|
||||||
})
|
}
|
||||||
|
|
||||||
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() {
|
BeforeEach(func() {
|
||||||
fixedTime = time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC)
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
tempDir = GinkgoT().TempDir()
|
||||||
|
|
||||||
|
// Setup config
|
||||||
|
conf.Server.Plugins.Enabled = true
|
||||||
|
conf.Server.Plugins.Folder = tempDir
|
||||||
|
|
||||||
|
// Create a command for testing
|
||||||
|
cmd = &cobra.Command{Use: "test"}
|
||||||
|
|
||||||
|
// Setup stdout capture
|
||||||
|
origStdout = os.Stdout
|
||||||
|
var err error
|
||||||
|
outReader, stdOut, err = os.Pipe()
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
os.Stdout = stdOut
|
||||||
|
|
||||||
|
DeferCleanup(func() {
|
||||||
|
os.Stdout = origStdout
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
It("includes Users, Libraries, Permissions, Created, Updated in text output", func() {
|
AfterEach(func() {
|
||||||
p := &model.Plugin{
|
os.Stdout = origStdout
|
||||||
ID: "myplugin",
|
if stdOut != nil {
|
||||||
Manifest: `{"name":"My Plugin","version":"1.0.0","author":"me","permissions":{"users":{},"subsonicapi":{}}}`,
|
stdOut.Close()
|
||||||
Enabled: true,
|
|
||||||
Users: "alice,bob",
|
|
||||||
Libraries: "1,2",
|
|
||||||
CreatedAt: fixedTime,
|
|
||||||
UpdatedAt: fixedTime.Add(time.Hour),
|
|
||||||
}
|
}
|
||||||
out, err := formatPluginInfo(p, "text")
|
if outReader != nil {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
outReader.Close()
|
||||||
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() {
|
Describe("Plugin list command", func() {
|
||||||
p := &model.Plugin{ID: "x", Manifest: `{}`, Users: "alice"}
|
It("should list installed plugins", func() {
|
||||||
out, err := formatPluginInfo(p, "json")
|
// Create test plugins
|
||||||
Expect(err).ToNot(HaveOccurred())
|
createTestPlugin("plugin1", "Test Author", "1.0.0", []string{"MetadataAgent"})
|
||||||
Expect(out).To(ContainSubstring(`"x"`))
|
createTestPlugin("plugin2", "Another Author", "2.1.0", []string{"Scrobbler"})
|
||||||
})
|
|
||||||
})
|
// Execute command
|
||||||
|
pluginList(cmd, []string{})
|
||||||
var _ = Describe("formatManifestInfo enriched text", func() {
|
|
||||||
It("includes Permissions when declared", func() {
|
// Verify output
|
||||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
output := captureOutput(outReader)
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
Expect(output).To(ContainSubstring("plugin1"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(output).To(ContainSubstring("Test Author"))
|
||||||
Expect(out).To(ContainSubstring("http"))
|
Expect(output).To(ContainSubstring("1.0.0"))
|
||||||
Expect(out).To(ContainSubstring("Permissions:"))
|
Expect(output).To(ContainSubstring("MetadataAgent"))
|
||||||
})
|
|
||||||
|
Expect(output).To(ContainSubstring("plugin2"))
|
||||||
It("omits Permissions line when no permissions declared", func() {
|
Expect(output).To(ContainSubstring("Another Author"))
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a"}
|
Expect(output).To(ContainSubstring("2.1.0"))
|
||||||
out, err := formatManifestInfo(m, "abc123", "text")
|
Expect(output).To(ContainSubstring("Scrobbler"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
})
|
||||||
Expect(out).ToNot(ContainSubstring("Permissions:"))
|
})
|
||||||
})
|
|
||||||
|
Describe("Plugin info command", func() {
|
||||||
It("does not alter json output", func() {
|
It("should display information about an installed plugin", func() {
|
||||||
p := &plugins.Permissions{Http: &plugins.HTTPPermission{}}
|
// Create test plugin with multiple capabilities
|
||||||
m := &plugins.Manifest{Name: "P", Version: "1.0.0", Author: "a", Permissions: p}
|
createTestPlugin("test-plugin", "Test Author", "1.0.0",
|
||||||
out, err := formatManifestInfo(m, "abc123", "json")
|
[]string{"MetadataAgent", "Scrobbler"})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(out).To(ContainSubstring(`"name"`))
|
// Execute command
|
||||||
})
|
pluginInfo(cmd, []string{"test-plugin"})
|
||||||
})
|
|
||||||
|
// Verify output
|
||||||
var _ = Describe("rescanPlugins", func() {
|
output := captureOutput(outReader)
|
||||||
It("calls RescanPlugins on the manager", func() {
|
|
||||||
mgr := &tests.MockPluginManager{}
|
Expect(output).To(ContainSubstring("Name: test-plugin"))
|
||||||
err := rescanPlugins(context.Background(), mgr)
|
Expect(output).To(ContainSubstring("Author: Test Author"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(output).To(ContainSubstring("Version: 1.0.0"))
|
||||||
Expect(mgr.RescanPluginsCalls).To(Equal(1))
|
Expect(output).To(ContainSubstring("Description: Plugin for testing"))
|
||||||
|
Expect(output).To(ContainSubstring("Capabilities: MetadataAgent, Scrobbler"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Plugin remove command", func() {
|
||||||
|
It("should remove a regular plugin directory", func() {
|
||||||
|
// Create test plugin
|
||||||
|
pluginDir := createTestPlugin("regular-plugin", "Test Author", "1.0.0",
|
||||||
|
[]string{"MetadataAgent"})
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
pluginRemove(cmd, []string{"regular-plugin"})
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
output := captureOutput(outReader)
|
||||||
|
Expect(output).To(ContainSubstring("Plugin 'regular-plugin' removed successfully"))
|
||||||
|
|
||||||
|
// Verify directory is actually removed
|
||||||
|
_, err := os.Stat(pluginDir)
|
||||||
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("should remove only the symlink for a development plugin", func() {
|
||||||
|
// Create a real source directory
|
||||||
|
sourceDir := filepath.Join(GinkgoT().TempDir(), "dev-plugin-source")
|
||||||
|
Expect(os.MkdirAll(sourceDir, 0755)).To(Succeed())
|
||||||
|
|
||||||
|
manifest := `{
|
||||||
|
"name": "dev-plugin",
|
||||||
|
"author": "Dev Author",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Development plugin for testing",
|
||||||
|
"website": "https://test.navidrome.org/dev-plugin",
|
||||||
|
"capabilities": ["Scrobbler"],
|
||||||
|
"permissions": {}
|
||||||
|
}`
|
||||||
|
Expect(os.WriteFile(filepath.Join(sourceDir, "manifest.json"), []byte(manifest), 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a dummy WASM file
|
||||||
|
wasmContent := []byte("dummy wasm content for testing")
|
||||||
|
Expect(os.WriteFile(filepath.Join(sourceDir, "plugin.wasm"), wasmContent, 0600)).To(Succeed())
|
||||||
|
|
||||||
|
// Create a symlink in the plugins directory
|
||||||
|
symlinkPath := filepath.Join(tempDir, "dev-plugin")
|
||||||
|
Expect(os.Symlink(sourceDir, symlinkPath)).To(Succeed())
|
||||||
|
|
||||||
|
// Execute command
|
||||||
|
pluginRemove(cmd, []string{"dev-plugin"})
|
||||||
|
|
||||||
|
// Verify output
|
||||||
|
output := captureOutput(outReader)
|
||||||
|
Expect(output).To(ContainSubstring("Development plugin symlink 'dev-plugin' removed successfully"))
|
||||||
|
Expect(output).To(ContainSubstring("target directory preserved"))
|
||||||
|
|
||||||
|
// Verify the symlink is removed but source directory exists
|
||||||
|
_, err := os.Lstat(symlinkPath)
|
||||||
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
||||||
|
_, err = os.Stat(sourceDir)
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
183
cmd/root.go
183
cmd/root.go
|
|
@ -2,7 +2,6 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -10,9 +9,9 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||||
"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"
|
||||||
|
|
@ -23,12 +22,6 @@ import (
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"golang.org/x/sync/errgroup"
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
// Import adapters to register them
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -44,9 +37,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 +69,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 +91,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 +119,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 +126,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 +135,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 +161,45 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||||
// the one used by their last finished scan
|
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||||
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
|
||||||
libs, err := ds.Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
var names []string
|
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||||
for _, lib := range libs {
|
if err != nil {
|
||||||
if lib.PIDChanged() {
|
return false, err
|
||||||
names = append(names, lib.Name)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return names, nil
|
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInitialScan runs an initial scan of the music library if needed.
|
// runInitialScan runs an initial scan of the music library if needed.
|
||||||
func runInitialScan(ctx context.Context) func() error {
|
func runInitialScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
ds := CreateDataStore()
|
ds := CreateDataStore()
|
||||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
pidHasChanged, err := pidHashChanged(ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
scanNeeded := conf.Server.Scanner.ScanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
switch {
|
switch {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||||
case len(pidChangedLibs) > 0:
|
case pidHasChanged:
|
||||||
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
log.Warn(ctx, "PID config changed, performing full scan")
|
||||||
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
fullScanRequired = "1"
|
||||||
case inProgress:
|
case inProgress:
|
||||||
log.Warn(ctx, "Resuming interrupted scan")
|
log.Warn(ctx, "Resuming interrupted scan")
|
||||||
default:
|
default:
|
||||||
|
|
@ -303,24 +269,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 +313,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,70 +327,19 @@ 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 {
|
||||||
manager := GetPluginManager(ctx)
|
|
||||||
if !conf.Server.Plugins.Enabled {
|
if !conf.Server.Plugins.Enabled {
|
||||||
log.Debug("Plugin system is DISABLED")
|
log.Debug("Plugins are DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Info(ctx, "Starting plugin manager")
|
log.Info(ctx, "Starting plugin manager")
|
||||||
return manager.Start(ctx)
|
// Get the manager instance and scan for plugins
|
||||||
|
manager := GetPluginManager(ctx)
|
||||||
|
manager.ScanPlugins()
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -485,7 +380,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"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
88
cmd/scan.go
88
cmd/scan.go
|
|
@ -1,16 +1,11 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -24,14 +19,12 @@ var (
|
||||||
fullScan bool
|
fullScan bool
|
||||||
subprocess bool
|
subprocess bool
|
||||||
targets []string
|
targets []string
|
||||||
targetFile string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
|
||||||
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
|
||||||
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
|
scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
|
||||||
scanCmd.Flags().StringVar(&targetFile, "target-file", "", "path to file containing targets (one libraryID:folderPath per line)")
|
|
||||||
rootCmd.AddCommand(scanCmd)
|
rootCmd.AddCommand(scanCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,20 +37,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 +53,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,19 +69,12 @@ func runScanner(ctx context.Context) {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
defer db.Db().Close()
|
defer db.Db().Close()
|
||||||
ds := persistence.New(sqlDB)
|
ds := persistence.New(sqlDB)
|
||||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
pls := core.NewPlaylists(ds)
|
||||||
|
|
||||||
// Parse targets from command line or file
|
// Parse targets if provided
|
||||||
var scanTargets []model.ScanTarget
|
var scanTargets []model.ScanTarget
|
||||||
var err error
|
if len(targets) > 0 {
|
||||||
|
var err error
|
||||||
if targetFile != "" {
|
|
||||||
scanTargets, err = readTargetsFromFile(targetFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(ctx, "Failed to read targets from file", err)
|
|
||||||
}
|
|
||||||
log.Info(ctx, "Scanning specific folders from file", "numTargets", len(scanTargets))
|
|
||||||
} else if len(targets) > 0 {
|
|
||||||
scanTargets, err = model.ParseTargets(targets)
|
scanTargets, err = model.ParseTargets(targets)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to parse targets", err)
|
log.Fatal(ctx, "Failed to parse targets", err)
|
||||||
|
|
@ -102,16 +82,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,48 +91,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTargetsFromFile reads scan targets from a file, one per line.
|
|
||||||
// Each line should be in the format "libraryID:folderPath".
|
|
||||||
// Empty lines and lines starting with # are ignored.
|
|
||||||
func readTargetsFromFile(filePath string) ([]model.ScanTarget, error) {
|
|
||||||
file, err := os.Open(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to open target file: %w", err)
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
var targetStrings []string
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
// Skip empty lines and comments
|
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
targetStrings = append(targetStrings, line)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read target file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return model.ParseTargets(targetStrings)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
104
cmd/scan_test.go
104
cmd/scan_test.go
|
|
@ -1,104 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/scanner"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("trackScanInteractively", func() {
|
|
||||||
It("reports changes and scan errors", func() {
|
|
||||||
progress := make(chan *scanner.ProgressInfo, 2)
|
|
||||||
progress <- &scanner.ProgressInfo{ChangesDetected: true}
|
|
||||||
progress <- &scanner.ProgressInfo{Error: "scan failed"}
|
|
||||||
close(progress)
|
|
||||||
|
|
||||||
changesDetected, err := trackScanInteractively(context.Background(), progress)
|
|
||||||
Expect(changesDetected).To(BeTrue())
|
|
||||||
Expect(err).To(MatchError("scan failed"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("readTargetsFromFile", func() {
|
|
||||||
var tempDir string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
var err error
|
|
||||||
tempDir, err = os.MkdirTemp("", "navidrome-test-")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
AfterEach(func() {
|
|
||||||
os.RemoveAll(tempDir)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reads valid targets from file", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "1:Music/Rock\n2:Music/Jazz\n3:Classical\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(3))
|
|
||||||
Expect(targets[0]).To(Equal(model.ScanTarget{LibraryID: 1, FolderPath: "Music/Rock"}))
|
|
||||||
Expect(targets[1]).To(Equal(model.ScanTarget{LibraryID: 2, FolderPath: "Music/Jazz"}))
|
|
||||||
Expect(targets[2]).To(Equal(model.ScanTarget{LibraryID: 3, FolderPath: "Classical"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("skips empty lines", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "1:Music/Rock\n\n2:Music/Jazz\n\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("trims whitespace", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := " 1:Music/Rock \n\t2:Music/Jazz\t\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(2))
|
|
||||||
Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
|
|
||||||
Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error for non-existent file", func() {
|
|
||||||
_, err := readTargetsFromFile("/nonexistent/file.txt")
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
Expect(err.Error()).To(ContainSubstring("failed to open target file"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error for invalid target format", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "invalid-format\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
_, err = readTargetsFromFile(filePath)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles mixed valid and empty lines", func() {
|
|
||||||
filePath := filepath.Join(tempDir, "targets.txt")
|
|
||||||
content := "\n1:Music/Rock\n\n\n2:Music/Jazz\n\n"
|
|
||||||
err := os.WriteFile(filePath, []byte(content), 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
targets, err := readTargetsFromFile(filePath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(targets).To(HaveLen(2))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
var searchRebuildForce bool
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(searchRoot)
|
|
||||||
|
|
||||||
searchRebuildCmd.Flags().BoolVarP(&searchRebuildForce, "force", "f", false, "bypass rebuild confirmation")
|
|
||||||
searchRoot.AddCommand(searchRebuildCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
searchRoot = &cobra.Command{
|
|
||||||
Use: "search",
|
|
||||||
Short: "Search index maintenance",
|
|
||||||
}
|
|
||||||
|
|
||||||
searchRebuildCmd = &cobra.Command{
|
|
||||||
Use: "rebuild",
|
|
||||||
Short: "Rebuild the full-text search index",
|
|
||||||
Long: "Drop and rebuild the full-text search index from the library data. Fixes a corrupted " +
|
|
||||||
"or desynced search index without any data loss. Note that 'navidrome doctor' detects a " +
|
|
||||||
"corrupted index, but cannot tell when the index has merely drifted out of sync with the " +
|
|
||||||
"library. This must be done offline",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runSearchRebuild(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
func runSearchRebuild(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
if !searchRebuildForce && !confirmYES("This will rebuild the search index. Make sure Navidrome is not running.") {
|
|
||||||
log.Warn("Rebuild cancelled")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("Rebuilding the search index...")
|
|
||||||
err := db.RebuildFTS(ctx, db.Db())
|
|
||||||
db.Close(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Error rebuilding the search index", err)
|
|
||||||
}
|
|
||||||
fmt.Println("Search index rebuilt successfully.")
|
|
||||||
}
|
|
||||||
43
cmd/svc.go
43
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
type svcControl struct {
|
type svcControl struct {
|
||||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
|
||||||
p.done = make(chan struct{})
|
p.done = make(chan struct{})
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
err := runNavidrome(p.ctx)
|
runNavidrome(p.ctx)
|
||||||
close(p.done)
|
close(p.done)
|
||||||
// service.Run() only returns when it gets a stop request, so exit here to let the
|
|
||||||
// service manager see the failure and restart the service
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +74,15 @@ func (p *svcControl) Stop(service.Service) error {
|
||||||
var svcInstance = sync.OnceValue(func() service.Service {
|
var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
options := make(service.KeyValue)
|
options := make(service.KeyValue)
|
||||||
options["Restart"] = "on-failure"
|
options["Restart"] = "on-failure"
|
||||||
options["SuccessExitStatus"] = "SIGKILL"
|
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||||
options["UserService"] = false
|
options["UserService"] = false
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder
|
||||||
options["SystemdScript"] = systemdScript
|
options["SystemdScript"] = systemdScript
|
||||||
if conf.Server.LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
options["LogOutput"] = false
|
options["LogOutput"] = false
|
||||||
} else {
|
} else {
|
||||||
options["LogOutput"] = true
|
options["LogOutput"] = true
|
||||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder
|
||||||
}
|
}
|
||||||
svcConfig := &service.Config{
|
svcConfig := &service.Config{
|
||||||
UserName: installUser,
|
UserName: installUser,
|
||||||
|
|
@ -136,11 +131,11 @@ func buildInstallCmd() *cobra.Command {
|
||||||
println("Installing service with:")
|
println("Installing service with:")
|
||||||
println(" working directory: " + executablePath())
|
println(" working directory: " + executablePath())
|
||||||
println(" music folder: " + conf.Server.MusicFolder)
|
println(" music folder: " + conf.Server.MusicFolder)
|
||||||
println(" data folder: " + conf.Server.DataFolder.String())
|
println(" data folder: " + conf.Server.DataFolder)
|
||||||
if conf.Server.LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
println(" log file: " + conf.Server.LogFile)
|
println(" log file: " + conf.Server.LogFile)
|
||||||
} else {
|
} else {
|
||||||
println(" logs folder: " + conf.Server.DataFolder.String())
|
println(" logs folder: " + conf.Server.DataFolder)
|
||||||
}
|
}
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||||
|
|
@ -237,22 +232,22 @@ func buildExecuteCmd() *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemdScript = `[Unit]
|
const systemdScript = `[Unit]
|
||||||
Description={{Description}}
|
Description={{.Description}}
|
||||||
ConditionFileIsExecutable={{Path | cmdEscape}}
|
ConditionFileIsExecutable={{.Path|cmdEscape}}
|
||||||
{{range Dependencies}}{{.}}
|
{{range $i, $dep := .Dependencies}}
|
||||||
{{end}}
|
{{$dep}} {{end}}
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
StartLimitInterval=5
|
StartLimitInterval=5
|
||||||
StartLimitBurst=10
|
StartLimitBurst=10
|
||||||
ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
|
ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
|
||||||
{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}{{end}}
|
{{if .WorkingDirectory}}WorkingDirectory={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||||
{{if UserName}}User={{UserName}}{{end}}
|
{{if .UserName}}User={{.UserName}}{{end}}
|
||||||
{{if Restart}}Restart={{Restart}}{{end}}
|
{{if .Restart}}Restart={{.Restart}}{{end}}
|
||||||
{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}{{end}}
|
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
|
||||||
TimeoutStopSec=20
|
TimeoutStopSec=20
|
||||||
RestartSec=120
|
RestartSec=120
|
||||||
EnvironmentFile=-/etc/sysconfig/{{Name}}
|
EnvironmentFile=-/etc/sysconfig/{{.Name}}
|
||||||
Environment="ND_SYSTEMD_PRIORITY_LOGGING=1"
|
|
||||||
|
|
||||||
DevicePolicy=closed
|
DevicePolicy=closed
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
|
|
@ -264,7 +259,7 @@ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
RestrictNamespaces=yes
|
RestrictNamespaces=yes
|
||||||
RestrictRealtime=yes
|
RestrictRealtime=yes
|
||||||
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
SystemCallFilter=~@clock @debug @module @mount @obsolete @reboot @setuid @swap
|
||||||
{{if WorkingDirectory}}ReadWritePaths={{WorkingDirectory | cmdEscape}}{{end}}
|
{{if .WorkingDirectory}}ReadWritePaths={{.WorkingDirectory|cmdEscape}}{{end}}
|
||||||
ProtectSystem=full
|
ProtectSystem=full
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("systemdScript template", func() {
|
|
||||||
systemdKeys := map[string]bool{
|
|
||||||
"Description": true, "Path": true, "Name": true, "Dependencies": true,
|
|
||||||
"Arguments": true, "ChRoot": true, "WorkingDirectory": true,
|
|
||||||
"UserName": true, "ReloadSignal": true, "PIDFile": true,
|
|
||||||
"LogDirectory": true, "OutputFileSupport": true, "LimitNOFILE": true,
|
|
||||||
"Restart": true, "SuccessExitStatus": true, "EnvVars": true,
|
|
||||||
}
|
|
||||||
systemdFuncs := map[string]bool{"cmd": true, "cmdEscape": true}
|
|
||||||
|
|
||||||
actionRe := regexp.MustCompile(`\{\{(.*?)\}\}`)
|
|
||||||
|
|
||||||
parseAction := func(action string) (key string, funcs []string) {
|
|
||||||
action = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(action, "-"), "-"))
|
|
||||||
kw, rest, _ := strings.Cut(action, " ")
|
|
||||||
switch kw {
|
|
||||||
case "end", "else":
|
|
||||||
return "", nil
|
|
||||||
case "if", "range":
|
|
||||||
return strings.TrimSpace(rest), nil
|
|
||||||
}
|
|
||||||
parts := strings.Split(action, "|")
|
|
||||||
for _, p := range parts[1:] {
|
|
||||||
funcs = append(funcs, strings.TrimSpace(p))
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[0]), funcs
|
|
||||||
}
|
|
||||||
|
|
||||||
It("only references keys and functions the service library provides", func() {
|
|
||||||
matches := actionRe.FindAllStringSubmatch(systemdScript, -1)
|
|
||||||
Expect(matches).ToNot(BeEmpty())
|
|
||||||
|
|
||||||
for _, m := range matches {
|
|
||||||
key, funcs := parseAction(m[1])
|
|
||||||
if key != "" && key != "." {
|
|
||||||
Expect(systemdKeys).To(HaveKey(key),
|
|
||||||
"template action %q uses a key unknown to kardianos/service", m[0])
|
|
||||||
}
|
|
||||||
for _, fn := range funcs {
|
|
||||||
Expect(systemdFuncs).To(HaveKey(fn),
|
|
||||||
"template action %q uses an unknown pipeline function", m[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
err := ds.WithTx(func(tx model.DataStore) error {
|
||||||
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
return fmt.Errorf("existing user '%s'", userID)
|
return fmt.Errorf("existing user '%s'", userID)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
||||||
return libraryError(user.Libraries)
|
return libraryError(user.Libraries)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().Put(ctx, &user)
|
err = tx.User(ctx).Put(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
var user *model.User
|
var user *model.User
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
err = ds.WithTx(func(tx model.DataStore) error {
|
||||||
count, err := tx.User().CountAll(ctx)
|
count, err := tx.User(ctx).CountAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.User().Delete(ctx, user.ID)
|
return tx.User(ctx).Delete(user.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if setAdmin && !user.IsAdmin {
|
if setAdmin && !user.IsAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx)
|
libraries, err := tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().Put(ctx, user)
|
err := tx.User(ctx).Put(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -393,11 +393,13 @@ func runUserList(ctx context.Context) {
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
userList, err := ds.User().ReadAll(ctx)
|
users, err := ds.User(ctx).ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userList := users.(model.Users)
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
|
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
206
cmd/wire_gen.go
206
cmd/wire_gen.go
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by Wire. DO NOT EDIT.
|
// Code generated by Wire. DO NOT EDIT.
|
||||||
|
|
||||||
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo sqlite_fts5"
|
//go:generate go run -mod=mod github.com/google/wire/cmd/wire gen -tags "netgo"
|
||||||
//go:build !wireinject
|
//go:build !wireinject
|
||||||
// +build !wireinject
|
// +build !wireinject
|
||||||
|
|
||||||
|
|
@ -9,41 +9,30 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"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/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/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/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "github.com/navidrome/navidrome/adapters/deezer"
|
_ "github.com/navidrome/navidrome/adapters/taglib"
|
||||||
_ "github.com/navidrome/navidrome/adapters/gotaglib"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Injectors from wire_injectors.go:
|
// Injectors from wire_injectors.go:
|
||||||
|
|
@ -58,7 +47,9 @@ func CreateServer() *server.Server {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
insights := metrics.GetInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
|
insights := metrics.GetInstance(dataStore, manager)
|
||||||
serverServer := server.New(dataStore, broker, insights)
|
serverServer := server.New(dataStore, broker, insights)
|
||||||
return serverServer
|
return serverServer
|
||||||
}
|
}
|
||||||
|
|
@ -67,22 +58,22 @@ 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)
|
playlists := core.NewPlaylists(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
insights := metrics.GetInstance(dataStore)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
insights := metrics.GetInstance(dataStore, manager)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
fileCache := artwork.GetImageCache()
|
||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
fFmpeg := ffmpeg.New()
|
||||||
user := core.NewUser(dataStore, manager)
|
|
||||||
maintenance := core.NewMaintenance(dataStore)
|
|
||||||
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)
|
||||||
quickConnect := quickconnect.GetInstance()
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
broker := events.GetBroker()
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
|
||||||
|
maintenance := core.NewMaintenance(dataStore)
|
||||||
|
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,63 +81,24 @@ 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()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, 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 := core.GetTranscodingCache()
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
mediaStreamer := core.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)
|
||||||
|
broker := events.GetBroker()
|
||||||
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, 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)
|
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
|
||||||
router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
fileCache := artwork.GetImageCache()
|
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
fFmpeg := ffmpeg.New()
|
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
players := core.NewPlayers(dataStore)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
|
||||||
uploader := artwork.NewUploader(dataStore)
|
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
|
||||||
matcherMatcher := matcher.New(dataStore)
|
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
|
||||||
quickConnect := quickconnect.GetInstance()
|
|
||||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
router := apiv1.New(dataStore)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,15 +106,17 @@ 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)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
transcodingCache := core.GetTranscodingCache()
|
||||||
|
mediaStreamer := core.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
share := core.NewShare(dataStore)
|
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,17 +134,12 @@ 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)
|
||||||
insights := metrics.GetInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
manager := plugins.GetManager(dataStore, metricsMetrics)
|
||||||
|
insights := metrics.GetInstance(dataStore, manager)
|
||||||
return insights
|
return insights
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -204,22 +153,34 @@ 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)
|
||||||
broker := events.GetBroker()
|
fileCache := artwork.GetImageCache()
|
||||||
uploader := artwork.NewUploader(dataStore)
|
fFmpeg := ffmpeg.New()
|
||||||
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, metricsMetrics)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
broker := events.GetBroker()
|
||||||
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
return modelScanner
|
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)
|
||||||
broker := events.GetBroker()
|
fileCache := artwork.GetImageCache()
|
||||||
uploader := artwork.NewUploader(dataStore)
|
fFmpeg := ffmpeg.New()
|
||||||
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, metricsMetrics)
|
||||||
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
|
provider := external.NewProvider(dataStore, agentsAgents)
|
||||||
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
|
||||||
|
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
|
||||||
|
broker := events.GetBroker()
|
||||||
|
playlists := core.NewPlaylists(dataStore)
|
||||||
|
modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
|
||||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
return watcher
|
return watcher
|
||||||
}
|
}
|
||||||
|
|
@ -231,46 +192,19 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||||
return playbackServer
|
return playbackServer
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkWorker() *artwork.Worker {
|
func getPluginManager() plugins.Manager {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
imageStore := artwork.GetImageStore()
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, 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 {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
broker := events.GetBroker()
|
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
|
||||||
return manager
|
return 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, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Watcher), new(scanner.Watcher)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|
|
||||||
|
|
@ -6,26 +6,21 @@ import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/google/wire"
|
"github.com/google/wire"
|
||||||
"github.com/navidrome/navidrome/adapters/lastfm"
|
|
||||||
"github.com/navidrome/navidrome/adapters/listenbrainz"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/lastfm"
|
||||||
|
"github.com/navidrome/navidrome/core/agents/listenbrainz"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"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 +31,21 @@ 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,
|
||||||
|
plugins.GetManager,
|
||||||
metrics.GetPrometheusInstance,
|
metrics.GetPrometheusInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
plugins.GetManager,
|
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
|
||||||
sonic.New,
|
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
|
||||||
wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)),
|
wire.Bind(new(metrics.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(core.Watcher), new(scanner.Watcher)),
|
||||||
wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateDataStore() model.DataStore {
|
func CreateDataStore() model.DataStore {
|
||||||
|
|
@ -87,18 +72,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 +90,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,26 +120,13 @@ func GetPlaybackServer() playback.PlaybackServer {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkWorker() *artwork.Worker {
|
func getPluginManager() plugins.Manager {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
func GetPluginManager(ctx context.Context) plugins.Manager {
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
artwork.NewTracingResolver,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPluginManager() *plugins.Manager {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
manager.SetSubsonicRouter(CreateSubsonicAPIRouter(ctx))
|
||||||
return manager
|
return manager
|
||||||
|
|
|
||||||
4
conf/buildtags/buildtags.go
Normal file
4
conf/buildtags/buildtags.go
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
package buildtags
|
||||||
|
|
||||||
|
// This file is left intentionally empty. It is used to make sure the package is not empty, in the case all
|
||||||
|
// required build tags are disabled.
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// Package buildtags provides compile-time enforcement of required build tags.
|
|
||||||
//
|
|
||||||
// Each file in this package is guarded by a build constraint and exports a variable
|
|
||||||
// that main.go references. If a required tag is missing during compilation, the build
|
|
||||||
// fails with an "undefined" error, directing the developer to use `make build`.
|
|
||||||
package buildtags
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
package buildtags
|
package buildtags
|
||||||
|
|
||||||
// The `netgo` tag is required when compiling the project. See https://github.com/navidrome/navidrome/issues/700
|
// NOTICE: This file was created to force the inclusion of the `netgo` tag when compiling the project.
|
||||||
|
// If the tag is not included, the compilation will fail because this variable won't be defined, and the `main.go`
|
||||||
|
// file requires it.
|
||||||
|
|
||||||
|
// Why this tag is required? See https://github.com/navidrome/navidrome/issues/700
|
||||||
|
|
||||||
var NETGO = true
|
var NETGO = true
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
//go:build sqlite_fts5
|
|
||||||
|
|
||||||
package buildtags
|
|
||||||
|
|
||||||
// FTS5 is required for full-text search. Without this tag, the SQLite driver
|
|
||||||
// won't include FTS5 support, causing runtime failures on migrations and search queries.
|
|
||||||
|
|
||||||
var SQLITE_FTS5 = true
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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,439 +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() {
|
|
||||||
It("parses single language", func() {
|
|
||||||
Expect(conf.ParseLanguages("en")).To(Equal([]string{"en"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("parses multiple comma-separated languages", func() {
|
|
||||||
Expect(conf.ParseLanguages("pt,en")).To(Equal([]string{"pt", "en"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("trims whitespace from languages", func() {
|
|
||||||
Expect(conf.ParseLanguages(" pt , en ")).To(Equal([]string{"pt", "en"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns default 'en' when empty", func() {
|
|
||||||
Expect(conf.ParseLanguages("")).To(Equal([]string{"en"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns default 'en' when only whitespace", func() {
|
|
||||||
Expect(conf.ParseLanguages(" ")).To(Equal([]string{"en"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles multiple languages with various spacing", func() {
|
|
||||||
Expect(conf.ParseLanguages("ja, pt, en")).To(Equal([]string{"ja", "pt", "en"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("scheduled DB analysis", func() {
|
|
||||||
It("is enabled by default", func() {
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("can be disabled", func() {
|
|
||||||
viper.Set("enablescheduleddbanalyze", false)
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ValidateURL", func() {
|
|
||||||
It("accepts a valid http URL", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "http://example.com/path")
|
|
||||||
Expect(fn()).To(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("accepts a valid https URL", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "https://example.com/path")
|
|
||||||
Expect(fn()).To(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL with no scheme", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "example.com/path")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL with an unsupported scheme", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "javascript://example.com/path")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("invalid scheme")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("accepts an empty URL (optional config)", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "")
|
|
||||||
Expect(fn()).To(Succeed())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("includes the option name in the error message", func() {
|
|
||||||
fn := conf.ValidateURL("MyOption", "ftp://example.com")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("MyOption")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL that cannot be parsed", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "://invalid")
|
|
||||||
Expect(fn()).To(HaveOccurred())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rejects a URL without a host", func() {
|
|
||||||
fn := conf.ValidateURL("TestOption", "http:///path")
|
|
||||||
Expect(fn()).To(MatchError(ContainSubstring("non-empty host is required")))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("NormalizeSearchBackend",
|
|
||||||
func(input, expected string) {
|
|
||||||
Expect(conf.NormalizeSearchBackend(input)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("accepts 'fts'", "fts", "fts"),
|
|
||||||
Entry("accepts 'legacy'", "legacy", "legacy"),
|
|
||||||
Entry("normalizes 'FTS' to lowercase", "FTS", "fts"),
|
|
||||||
Entry("normalizes 'Legacy' to lowercase", "Legacy", "legacy"),
|
|
||||||
Entry("trims whitespace", " fts ", "fts"),
|
|
||||||
Entry("falls back to 'fts' for 'fts5'", "fts5", "fts"),
|
|
||||||
Entry("falls back to 'fts' for unrecognized values", "invalid", "fts"),
|
|
||||||
Entry("falls back to 'fts' for empty string", "", "fts"),
|
|
||||||
)
|
|
||||||
|
|
||||||
DescribeTable("ToPascalCase",
|
|
||||||
func(input, expected string) {
|
|
||||||
Expect(conf.ToPascalCase(input)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("simple key", "address", "Address"),
|
|
||||||
Entry("dotted key", "scanner.schedule", "Scanner.Schedule"),
|
|
||||||
Entry("already capitalized", "Address", "Address"),
|
|
||||||
Entry("multi-segment", "lastfm.enabled", "Lastfm.Enabled"),
|
|
||||||
Entry("empty string", "", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
Describe("remapEnvVarKeysFromConfig", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("remaps ND_-prefixed keys to canonical keys", func() {
|
|
||||||
filename := filepath.Join("testdata", "cfg_nd_keys.toml")
|
|
||||||
conf.InitConfig(filename, false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
|
|
||||||
Expect(conf.Server.Port).To(Equal(4531))
|
|
||||||
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("exits with fatal error when both ND_ and canonical key exist", func() {
|
|
||||||
filename := filepath.Join("testdata", "cfg_nd_conflict.toml")
|
|
||||||
conf.InitConfig(filename, false)
|
|
||||||
|
|
||||||
Expect(func() { conf.Load(true) }).To(PanicWith(And(
|
|
||||||
ContainSubstring("ND_ADDRESS"),
|
|
||||||
ContainSubstring("Address"),
|
|
||||||
ContainSubstring("only needed for environment variables"),
|
|
||||||
)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does nothing when no ND_ keys are present", func() {
|
|
||||||
filename := filepath.Join("testdata", "cfg.toml")
|
|
||||||
conf.InitConfig(filename, false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
// Verify normal config loading still works
|
|
||||||
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("unknownConfigKeys", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports misplaced and misspelled options, as spelled in the config file", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_unknown_keys.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf(
|
|
||||||
"ArtistSplitExceptions", "EnableDownlods", "Whatever.Foo",
|
|
||||||
))
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("recovers the original casing in all supported formats",
|
|
||||||
func(file string) {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", file), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf("NotAnOption"))
|
|
||||||
},
|
|
||||||
Entry("TOML", "cfg_unknown_casing.toml"),
|
|
||||||
Entry("YAML", "cfg_unknown_casing.yaml"),
|
|
||||||
Entry("JSON", "cfg_unknown_casing.json"),
|
|
||||||
Entry("INI", "cfg_unknown_casing.ini"),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("does not report valid, deprecated or free-form keys", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("does not report the [default] section of INI files", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg.ini"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
DescribeTable("SuggestOptions",
|
|
||||||
func(key string, expected []string) {
|
|
||||||
Expect(conf.SuggestOptions(key)).To(Equal(expected))
|
|
||||||
},
|
|
||||||
Entry("suggests the section of a misplaced option", "artistsplitexceptions",
|
|
||||||
[]string{"Scanner.ArtistSplitExceptions"}),
|
|
||||||
Entry("suggests the section of a misplaced nested option", "backup.fuzzythreshold",
|
|
||||||
[]string{"Matcher.FuzzyThreshold"}),
|
|
||||||
Entry("suggests every section defining the option", "schedule",
|
|
||||||
[]string{"Backup.Schedule", "Scanner.Schedule"}),
|
|
||||||
Entry("suggests nothing for a typo", "enabledownlods", nil),
|
|
||||||
)
|
|
||||||
|
|
||||||
It("does not report ND_-prefixed keys, as they are remapped", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_nd_keys.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports ND_-prefixed keys that remap to no known option", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_nd_bogus.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION"))
|
|
||||||
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("migrates every deprecated option that has a replacement", func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.Search.FullString).To(BeTrue())
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("warns about each unrecognized option at startup", func() {
|
|
||||||
var logBuf bytes.Buffer
|
|
||||||
log.SetOutput(&logBuf)
|
|
||||||
DeferCleanup(func() { log.SetOutput(GinkgoWriter) })
|
|
||||||
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_warning_output.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(logBuf.String()).To(ContainSubstring(
|
|
||||||
"Option 'ArtistSplitExceptions' is not recognized and will be ignored. " +
|
|
||||||
"Did you mean 'Scanner.ArtistSplitExceptions'?"))
|
|
||||||
Expect(logBuf.String()).To(ContainSubstring(
|
|
||||||
"Option 'EnableDownlods' is not recognized and will be ignored"))
|
|
||||||
Expect(logBuf.String()).ToNot(ContainSubstring("ArtistJoiner"))
|
|
||||||
})
|
|
||||||
|
|
||||||
Context("with runtime-computed and removed options in the config", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_runtime_fields.toml"), false)
|
|
||||||
conf.Load(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports values computed during Load, which the config cannot set", func() {
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ContainElements("ConfigFile", "LastFM.Languages"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("never suggests a removed option", func() {
|
|
||||||
Expect(conf.SuggestOptions("id")).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps an explicit replacement over the deprecated value", func() {
|
|
||||||
Expect(conf.Server.Search.FullString).To(BeFalse())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("logFatal", func() {
|
|
||||||
var invalidPath string
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
|
|
||||||
// Create a file so that any path under it is invalid on all OSes
|
|
||||||
f, err := os.CreateTemp(GinkgoT().TempDir(), "blocker")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
f.Close()
|
|
||||||
invalidPath = filepath.Join(f.Name(), "subdir")
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is called when LoadFromFile gets an invalid config file", func() {
|
|
||||||
Expect(func() {
|
|
||||||
conf.LoadFromFile(filepath.Join(invalidPath, "file.toml"))
|
|
||||||
}).To(PanicWith(ContainSubstring("Error reading config file")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is called when LogFile path is not writable", func() {
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt"))
|
|
||||||
Expect(func() {
|
|
||||||
conf.Load(true)
|
|
||||||
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("creates the log file readable only by the owner", func() {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
Skip("file modes are not enforced on Windows")
|
|
||||||
}
|
|
||||||
logFile := filepath.Join(GinkgoT().TempDir(), "navidrome.log")
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("logfile", logFile)
|
|
||||||
DeferCleanup(log.SetOutput, os.Stderr)
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
info, err := os.Stat(logFile)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(info.Mode().Perm()).To(Equal(os.FileMode(0600)))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is called when BaseURL is invalid", func() {
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("baseurl", "://invalid")
|
|
||||||
Expect(func() {
|
|
||||||
conf.Load(true)
|
|
||||||
}).To(PanicWith(ContainSubstring("Invalid BaseURL")))
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("ValidateByteSize", func() {
|
|
||||||
DescribeTable("accepts valid size values",
|
|
||||||
func(input string) {
|
|
||||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
|
|
||||||
},
|
|
||||||
Entry("megabytes", "10MB"),
|
|
||||||
Entry("gigabytes", "1GB"),
|
|
||||||
Entry("raw bytes", "10485760"),
|
|
||||||
Entry("mebibytes", "10MiB"),
|
|
||||||
Entry("lower case", "50mb"),
|
|
||||||
)
|
|
||||||
|
|
||||||
DescribeTable("rejects invalid size values",
|
|
||||||
func(input string) {
|
|
||||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
|
|
||||||
},
|
|
||||||
Entry("garbage string", "not-a-size"),
|
|
||||||
Entry("negative-looking", "-10MB"),
|
|
||||||
Entry("zero", "0"),
|
|
||||||
Entry("zero with unit", "0MB"),
|
|
||||||
Entry("overflows int64", "9223372036854775808"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("MaxImageSize floor", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is raised to MaxImageUploadSize when configured lower", func() {
|
|
||||||
viper.SetDefault("maximagesize", "5MB")
|
|
||||||
viper.SetDefault("maximageuploadsize", "50MB")
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.MaxImageSize).To(Equal("50MB"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps a larger MaxImageSize unchanged", func() {
|
|
||||||
viper.SetDefault("maximagesize", "30MB")
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.MaxImageSize).To(Equal("30MB"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Scanner.Extractor", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
viper.Reset()
|
|
||||||
conf.SetViperDefaults()
|
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
|
||||||
viper.SetDefault("loglevel", "error")
|
|
||||||
conf.ResetConf()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to taglib for an unknown extractor", func() {
|
|
||||||
viper.SetDefault("scanner.extractor", "ffmpeg")
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps taglib", func() {
|
|
||||||
conf.Load(true)
|
|
||||||
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("EnforceNonRootUser", func() {
|
|
||||||
It("defaults to false", func() {
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("allows startup for non-root users when enabled", func() {
|
|
||||||
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 1000))
|
|
||||||
viper.Set("enforcenonrootuser", true)
|
|
||||||
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("exits when enabled and running as root without having created a data folder", func() {
|
|
||||||
// Create a path that doesn't exist yet
|
|
||||||
tempBase := GinkgoT().TempDir()
|
|
||||||
nonExistentDataFolder := filepath.Join(tempBase, "nonexistent", "data")
|
|
||||||
DeferCleanup(conf.SetRuntimeInfoForTest("linux", 0))
|
|
||||||
viper.Set("enforcenonrootuser", true)
|
|
||||||
viper.Set("datafolder", nonExistentDataFolder)
|
|
||||||
|
|
||||||
// Attempt to load config as root user - should fail before creating directories
|
|
||||||
Expect(func() {
|
|
||||||
conf.Load(true)
|
|
||||||
}).To(PanicWith(ContainSubstring("EnforceNonRootUser is enabled but Navidrome is running as root")))
|
|
||||||
|
|
||||||
// Verify that the data folder was NOT created
|
|
||||||
Expect(nonExistentDataFolder).ToNot(BeAnExistingFile())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("is a no-op on non-unix platforms", func() {
|
|
||||||
DeferCleanup(conf.SetRuntimeInfoForTest("windows", 0))
|
|
||||||
viper.Set("enforcenonrootuser", true)
|
|
||||||
|
|
||||||
conf.Load(true)
|
|
||||||
|
|
||||||
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("should load configuration from",
|
DescribeTable("should load configuration from",
|
||||||
|
|
@ -492,73 +52,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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -5,34 +5,3 @@ func ResetConf() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var SetViperDefaults = setViperDefaults
|
var SetViperDefaults = setViperDefaults
|
||||||
|
|
||||||
var ParseLanguages = parseLanguages
|
|
||||||
|
|
||||||
var ValidateURL = validateURL
|
|
||||||
|
|
||||||
var NormalizeSearchBackend = normalizeSearchBackend
|
|
||||||
|
|
||||||
var ToPascalCase = toPascalCase
|
|
||||||
|
|
||||||
var ValidateByteSize = validateByteSize
|
|
||||||
|
|
||||||
func SetRuntimeInfoForTest(goos string, euid int) func() {
|
|
||||||
oldGOOS := currentGOOS
|
|
||||||
oldEUID := getEUID
|
|
||||||
currentGOOS = func() string { return goos }
|
|
||||||
getEUID = func() int { return euid }
|
|
||||||
return func() {
|
|
||||||
currentGOOS = oldGOOS
|
|
||||||
getEUID = oldEUID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetLogFatal(f func(...any)) func() {
|
|
||||||
old := logFatal
|
|
||||||
logFatal = f
|
|
||||||
return func() { logFatal = old }
|
|
||||||
}
|
|
||||||
|
|
||||||
var UnknownConfigKeys = unknownConfigKeys
|
|
||||||
|
|
||||||
var SuggestOptions = suggestOptions
|
|
||||||
|
|
|
||||||
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
|
|
||||||
3
conf/testdata/cfg_nd_bogus.toml
vendored
3
conf/testdata/cfg_nd_bogus.toml
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
MusicFolder = "/toml/music"
|
|
||||||
ND_TOTALLY_BOGUS_OPTION = true
|
|
||||||
ND_SCANNER_SCHEDULE = "@every 1h"
|
|
||||||
2
conf/testdata/cfg_nd_conflict.toml
vendored
2
conf/testdata/cfg_nd_conflict.toml
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
ND_ADDRESS = "127.0.0.1"
|
|
||||||
Address = "0.0.0.0"
|
|
||||||
3
conf/testdata/cfg_nd_keys.toml
vendored
3
conf/testdata/cfg_nd_keys.toml
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
ND_ADDRESS = "127.0.0.1"
|
|
||||||
ND_PORT = 4531
|
|
||||||
ND_SCANNER_SCHEDULE = "@every 1h"
|
|
||||||
10
conf/testdata/cfg_runtime_fields.toml
vendored
10
conf/testdata/cfg_runtime_fields.toml
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
MusicFolder = "/toml/music"
|
|
||||||
SearchFullString = true
|
|
||||||
ConfigFile = "/somewhere/else"
|
|
||||||
ID = "oops"
|
|
||||||
|
|
||||||
[Search]
|
|
||||||
FullString = false
|
|
||||||
|
|
||||||
[LastFM]
|
|
||||||
Languages = ["pt"]
|
|
||||||
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