mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-13 13:48:39 +01:00
Compare commits
87 commits
master
...
artwork-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a998a329e6 | ||
|
|
42fd263cdf | ||
|
|
f92507b152 | ||
|
|
aba7ed925c | ||
|
|
aa6c0b1f17 | ||
|
|
e9c15d7fcb | ||
|
|
3eaa21229d | ||
|
|
9dd306eb10 | ||
|
|
8d715ba2fa | ||
|
|
50ada9ad29 | ||
|
|
ed4178a6a9 | ||
|
|
ce06599288 | ||
|
|
f016192eec | ||
|
|
cfca4a2433 | ||
|
|
49039fab47 | ||
|
|
66d3d23149 | ||
|
|
f4e14e9c1a | ||
|
|
d15d85ad0c | ||
|
|
0781c4a9b2 | ||
|
|
ca4220b029 | ||
|
|
c2d7ae773c | ||
|
|
dfd4bec270 | ||
|
|
0d1df1648e | ||
|
|
937e58e5fb | ||
|
|
8fea7efa50 | ||
|
|
25b32f9706 | ||
|
|
313998fd65 | ||
|
|
3a9dadfe34 | ||
|
|
05ba549843 | ||
|
|
5179691811 | ||
|
|
2ab1323b28 | ||
|
|
3b7cbf41dd | ||
|
|
39e939686b | ||
|
|
190c291e61 | ||
|
|
b7f94f6727 | ||
|
|
f34a386137 | ||
|
|
fc55e8bf16 | ||
|
|
9ce51cf575 | ||
|
|
b172ce4296 | ||
|
|
bba0eab3a5 | ||
|
|
f614850ff0 | ||
|
|
ba2290af6d | ||
|
|
55608b2d20 | ||
|
|
7713a6d6b2 | ||
|
|
bc30ce67c6 | ||
|
|
d6434b9929 | ||
|
|
b3526c0fba | ||
|
|
3d32157403 | ||
|
|
5482784bfc | ||
|
|
6afcb93a9b | ||
|
|
67f6d8aee8 | ||
|
|
87095fab08 | ||
|
|
c57496d50d | ||
|
|
0fbbd01357 | ||
|
|
bab9b5cd3a | ||
|
|
454fd24833 | ||
|
|
c01e9b3184 | ||
|
|
1ed8ebf9b0 | ||
|
|
ad38cd1d58 | ||
|
|
25a05fd017 | ||
|
|
57c64e386a | ||
|
|
d6fc829f84 | ||
|
|
e0655dc882 | ||
|
|
608db503a7 | ||
|
|
967de74bf7 | ||
|
|
2efa697e52 | ||
|
|
1f818e7633 | ||
|
|
c04c8ee02a | ||
|
|
ebbe533c6a | ||
|
|
0147cc59b1 | ||
|
|
034cd17498 | ||
|
|
8147f7c40b | ||
|
|
bf614e66ad | ||
|
|
623b7d6a6c | ||
|
|
6f7f9c6463 | ||
|
|
8fd7ef19f3 | ||
|
|
1041e45ca7 | ||
|
|
4f835437a9 | ||
|
|
b16ef725c9 | ||
|
|
3e7685adc2 | ||
|
|
db16b3de9a | ||
|
|
b72597821a | ||
|
|
fcff9c63e7 | ||
|
|
14dd57052e | ||
|
|
f926539c04 | ||
|
|
6cce65f759 | ||
|
|
7aacb01f4f |
1013 changed files with 16287 additions and 61352 deletions
|
|
@ -4,7 +4,7 @@
|
|||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
|
||||
"VARIANT": "1.27",
|
||||
"VARIANT": "1.26",
|
||||
// Options
|
||||
"INSTALL_NODE": "true",
|
||||
"NODE_VERSION": "v24"
|
||||
|
|
|
|||
6
.github/FUNDING.yml
vendored
6
.github/FUNDING.yml
vendored
|
|
@ -1,10 +1,10 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
ko_fi: deluan
|
||||
github: deluan
|
||||
open_collective: navidrome
|
||||
liberapay: deluan
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: deluan
|
||||
liberapay: deluan
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
|
|
|
|||
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,74 +1,98 @@
|
|||
name: Bug report
|
||||
description: Report something that is not working as expected. You do not need to know the cause or how to fix it.
|
||||
name: Bug Report
|
||||
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
|
||||
title: "[Bug]: "
|
||||
labels: ["triage"]
|
||||
labels: ["bug", "triage"]
|
||||
#assignees:
|
||||
# - deluan
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for reporting a problem. For a possible security vulnerability, use [private reporting](https://github.com/navidrome/navidrome/security/advisories/new).
|
||||
### Thanks for taking the time to fill out this bug report!
|
||||
- type: checkboxes
|
||||
id: requirements
|
||||
attributes:
|
||||
label: "I confirm that:"
|
||||
options:
|
||||
- label: I have searched the existing [open and closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) and found no matching open report.
|
||||
- label: I have searched the existing [open AND closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) to see if an issue already exists for the bug I've encountered
|
||||
required: true
|
||||
- label: I'm using the latest Navidrome release and can reproduce the issue on it.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened
|
||||
description: Describe what you observed and how it affected you. Include any error message you saw. You do not need to diagnose the cause or suggest code changes. If a closed issue matches, link it here.
|
||||
validations:
|
||||
required: true
|
||||
- label: I'm using the latest version (your issue may have been fixed already)
|
||||
required: false
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Navidrome version
|
||||
description: Which Navidrome version or build are you using? If you cannot find it, enter 'not sure'. If you tried another version, mention whether the behavior changed.
|
||||
label: Version
|
||||
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior and why
|
||||
description: What did you expect, and why? If available, link documentation, an applicable API contract, or a previously working version. A personal expectation is useful context too; it does not by itself establish that the product should change. If you are unsure, say so.
|
||||
label: Current Behavior
|
||||
description: A concise description of what you're experiencing.
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: List the steps, relevant settings, and how often the problem occurs. If it happens only sometimes, describe what was happening when you noticed it and what you have already tried.
|
||||
label: Expected Behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this scenario...
|
||||
2. With this config...
|
||||
3. Click (or Execute) '...'
|
||||
4. See error...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: env
|
||||
attributes:
|
||||
label: Relevant environment
|
||||
description: >-
|
||||
Include only details relevant to this problem: server operating system, installation method, and the browser or client app with its version, if involved. Leave out details you do not know. Examples of installation methods: Docker, downloaded binary, package, or source build.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: config-logs
|
||||
label: Environment
|
||||
description: |
|
||||
examples:
|
||||
- **OS**: Ubuntu 20.04
|
||||
- **Browser**: Chrome 110.0.5481.177 on Windows 11
|
||||
- **Client**: DSub 5.5.1
|
||||
value: |
|
||||
- OS:
|
||||
- Browser:
|
||||
- Client:
|
||||
render: markdown
|
||||
- type: dropdown
|
||||
id: distribution
|
||||
attributes:
|
||||
label: Relevant configuration and logs
|
||||
description: >-
|
||||
Paste only the configuration settings or log excerpt related to this problem. Remove passwords, tokens, API keys, session cookies, private URLs, and identifying information before submitting. Debug logs are optional. If you cannot safely share an excerpt, explain what it shows instead. [Log guidance](https://www.navidrome.org/docs/faq/#-where-are-the-logs).
|
||||
label: How Navidrome is installed?
|
||||
multiple: false
|
||||
options:
|
||||
- Docker
|
||||
- Binary (from downloads page)
|
||||
- Package
|
||||
- Built from sources
|
||||
validations:
|
||||
required: false
|
||||
required: true
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
id: config
|
||||
attributes:
|
||||
label: Screenshots or additional context
|
||||
description: Add screenshots, recordings, links, or other observations that help explain the problem. Remove private information from attachments as well.
|
||||
validations:
|
||||
required: false
|
||||
label: Configuration
|
||||
description: Please copy and paste your `navidrome.toml` (and/or `docker-compose.yml`) configuration. This will be automatically formatted into code, so no need for backticks.
|
||||
render: toml
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output (change your `LogLevel` (`ND_LOGLEVEL`) to debug). This will be automatically formatted into code, so no need for backticks. ([Where I can find the logs?](https://www.navidrome.org/docs/faq/#where-are-the-logs))
|
||||
render: shell
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach screenshots by clicking this area to highlight it and then dragging files in.
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
|
|
|||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +1,8 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Ideas and behavior changes
|
||||
- name: Ideas for new features
|
||||
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
||||
about: Describe the problem, intended outcome, and use case before starting an implementation.
|
||||
about: This is the place to share and discuss new ideas and potentially new features.
|
||||
- name: Support requests
|
||||
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
||||
about: Ask for help with setup, configuration, or using Navidrome.
|
||||
about: This is the place to ask questions.
|
||||
|
|
|
|||
5
.github/actions/prepare-docker/action.yml
vendored
5
.github/actions/prepare-docker/action.yml
vendored
|
|
@ -68,11 +68,6 @@ runs:
|
|||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
# Runner IPs are shared, so anonymous base image pulls get rate-limited.
|
||||
buildkitd-config-inline: |
|
||||
[registry."docker.io"]
|
||||
mirrors = ["mirror.gcr.io"]
|
||||
|
||||
- name: Extract metadata for Docker image
|
||||
id: meta
|
||||
|
|
|
|||
23
.github/pull_request_template.md
vendored
23
.github/pull_request_template.md
vendored
|
|
@ -1,13 +1,8 @@
|
|||
### Description
|
||||
<!-- Please provide a clear and concise description of what this PR does and why it is needed. AI tools may help you draft, edit, or translate it, but review the final text and make sure you agree with it. Please be mindful of the maintainer's time. -->
|
||||
|
||||
**AI use** (check one):
|
||||
- [ ] Yes, AI helped generate or modify code or tests, draft substantive PR text, or create this PR
|
||||
- [ ] No
|
||||
<!-- Tool names and prompts are not required. -->
|
||||
<!-- Please provide a clear and concise description of what this PR does and why it is needed. -->
|
||||
|
||||
### Related Issues
|
||||
<!-- List related issues, such as "Fixes #123" or "Related to #456". When prior agreement is required, link the maintainer's agreement on the intended behavior and scope. Otherwise, explain why agreement is not needed, such as the small-change exception or a translation update. For a larger change, link the agreed design and identify this phase. -->
|
||||
<!-- List any related issues, e.g., "Fixes #123" or "Related to #456". -->
|
||||
|
||||
### Type of Change
|
||||
- [ ] Bug fix
|
||||
|
|
@ -17,29 +12,27 @@
|
|||
- [ ] Other (please describe):
|
||||
|
||||
### Checklist
|
||||
Please review and check all that apply. Leave unverified items unchecked and explain them under How to Test.
|
||||
Please review and check all that apply:
|
||||
|
||||
- [ ] My code follows the project’s coding style
|
||||
- [ ] I have tested the changes locally
|
||||
- [ ] I have added or updated documentation as needed
|
||||
- [ ] I have added tests that prove my fix/feature works (or explain why not)
|
||||
- [ ] All existing and new tests pass
|
||||
- [ ] I personally read and reviewed the entire diff and relevant code before opening this PR, and have done the same for every subsequent update, if any.
|
||||
- [ ] For code changes, I have the development knowledge to understand, test, and maintain the implementation, assess its security and performance implications, and answer review questions.
|
||||
|
||||
### How to Test
|
||||
<!-- Describe the steps to test your changes. Include setup, commands actually run, and results actually observed before and after the change. State how you personally checked the affected functionality. List failed or unrun checks, untested cases, and limitations. -->
|
||||
<!-- Describe the steps to test your changes. Include setup, commands, and expected results. -->
|
||||
|
||||
### Screenshots / Demos (if applicable)
|
||||
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
|
||||
|
||||
### Additional Notes
|
||||
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? Where relevant, explain important tradeoffs, new dependencies or ongoing maintenance costs, and simpler alternatives considered. -->
|
||||
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? -->
|
||||
|
||||
<!--
|
||||
**Tips for Contributors:**
|
||||
- Be concise but thorough.
|
||||
- If your PR is large, consider breaking it into smaller PRs within the agreed design and scope.
|
||||
- Review timing depends on maintainer availability; please do not tag the maintainer for faster review.
|
||||
- If your PR is large, consider breaking it into smaller PRs.
|
||||
- Tag the maintainer if you need a prompt review.
|
||||
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
|
||||
-->
|
||||
-->
|
||||
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 }}
|
||||
11
.github/workflows/download-link-on-pr.yml
vendored
11
.github/workflows/download-link-on-pr.yml
vendored
|
|
@ -34,19 +34,16 @@ jobs:
|
|||
}
|
||||
|
||||
const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id});
|
||||
const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-'));
|
||||
if (!downloadable.length) {
|
||||
if (!artifacts.length) {
|
||||
return core.error(`No artifacts found`);
|
||||
}
|
||||
const header = `Download the artifacts for this pull request:`;
|
||||
let body = `${header}\n`;
|
||||
for (const art of downloadable) {
|
||||
let body = `Download the artifacts for this pull request:\n`;
|
||||
for (const art of artifacts) {
|
||||
body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`;
|
||||
}
|
||||
|
||||
const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number});
|
||||
// Match on the body too: octocov also comments as github-actions[bot].
|
||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header));
|
||||
const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]');
|
||||
if (existing_comment) {
|
||||
core.info(`Updating comment ${existing_comment.id}`);
|
||||
await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body});
|
||||
|
|
|
|||
138
.github/workflows/pipeline.yml
vendored
138
.github/workflows/pipeline.yml
vendored
|
|
@ -68,18 +68,12 @@ jobs:
|
|||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# 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
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: ${{ steps.golangci-version.outputs.version }}
|
||||
version: latest
|
||||
problem-matchers: true
|
||||
args: --timeout 3m
|
||||
args: --timeout 2m
|
||||
|
||||
- name: Run go goimports
|
||||
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
|
||||
|
|
@ -92,23 +86,8 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve OpenAPI tool versions
|
||||
id: api-tools
|
||||
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache OpenAPI tools
|
||||
uses: actions/cache@v6
|
||||
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 ./...
|
||||
run: go generate ./...
|
||||
- name: Verify no changes from go generate
|
||||
run: |
|
||||
git status --porcelain
|
||||
|
|
@ -117,12 +96,6 @@ jobs:
|
|||
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
|
||||
|
|
@ -158,10 +131,8 @@ jobs:
|
|||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
# Name must stay unique across the workflow: octocov matches step names
|
||||
# by name across every job, and waits for each match to finish.
|
||||
- name: Test with coverage
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$')
|
||||
- name: Test
|
||||
run: go test -shuffle=on -tags netgo,sqlite_fts5 -race ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
run: |
|
||||
|
|
@ -170,84 +141,6 @@ jobs:
|
|||
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
|
||||
|
|
@ -314,12 +207,12 @@ jobs:
|
|||
run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v
|
||||
|
||||
- name: Test ndpgen
|
||||
shell: bash
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd plugins/cmd/ndpgen
|
||||
cd plugins\cmd\ndpgen
|
||||
go test -shuffle=on -v
|
||||
go build -o ndpgen.exe .
|
||||
./ndpgen.exe --help
|
||||
.\ndpgen.exe --help
|
||||
|
||||
js:
|
||||
name: Test JS code
|
||||
|
|
@ -385,7 +278,7 @@ jobs:
|
|||
|
||||
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-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations]
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ]
|
||||
|
|
@ -519,18 +412,13 @@ jobs:
|
|||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
||||
|
||||
push-manifest-dockerhub:
|
||||
name: Push to Docker Hub (${{ matrix.repository }})
|
||||
name: Push to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs: [build, check-push-enabled]
|
||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Always push to DOCKER_HUB_REPO. The upstream repository also pushes to the navidrome/navidrome org repository.
|
||||
repository: ${{ fromJSON(github.repository == 'navidrome/navidrome' && format('["{0}","navidrome/navidrome"]', vars.DOCKER_HUB_REPO) || format('["{0}"]', vars.DOCKER_HUB_REPO)) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
|
|
@ -546,7 +434,7 @@ jobs:
|
|||
id: docker
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
hub_repository: ${{ matrix.repository }}
|
||||
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
|
||||
|
|
@ -563,7 +451,7 @@ jobs:
|
|||
|
||||
- name: Inspect image in Docker Hub
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
|
||||
|
||||
cleanup-digests:
|
||||
name: Cleanup digest artifacts
|
||||
|
|
@ -575,7 +463,7 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||
done
|
||||
|
||||
|
|
|
|||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -40,11 +40,3 @@ 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/
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ linters:
|
|||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- containedctx
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- durationcheck
|
||||
|
|
@ -28,9 +27,6 @@ linters:
|
|||
disable:
|
||||
- staticcheck
|
||||
settings:
|
||||
errcheck:
|
||||
exclude-functions:
|
||||
- (*github.com/zeebo/xxh3.Hasher).Write
|
||||
gocritic:
|
||||
disable-all: true
|
||||
enabled-checks:
|
||||
|
|
@ -58,9 +54,6 @@ linters:
|
|||
- gosec
|
||||
path: _test\.go
|
||||
text: "G703"
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- containedctx
|
||||
- path-except: 'db/migrations/'
|
||||
linters:
|
||||
- forbidigo
|
||||
|
|
@ -76,7 +69,6 @@ linters:
|
|||
- examples$
|
||||
- node_modules
|
||||
- _gen\.go$
|
||||
- .worktrees
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
|
|
|
|||
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}
|
||||
111
CONTRIBUTING.md
111
CONTRIBUTING.md
|
|
@ -1,66 +1,93 @@
|
|||
# Navidrome Contribution Guide
|
||||
|
||||
Navidrome has one maintainer and limited review capacity. We welcome contributions that fit the project and remain practical to maintain. A smaller number of focused, well-understood contributions is more useful than a large volume of code that needs extensive rework.
|
||||
Navidrome is a streaming service which allows you to enjoy your music collection from anywhere. We'd welcome you to contribute to our open source project and make Navidrome even better. There are some basic guidelines which you need to follow if you like to contribute to Navidrome.
|
||||
|
||||
## Support and conduct
|
||||
- [Asking Support Questions](#asking-support-questions)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Issues](#issues)
|
||||
- [Pull Requests](#pull-requests)
|
||||
|
||||
Use [Discussions → Q&A](https://github.com/navidrome/navidrome/discussions/categories/q-a) for support. Please follow the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
|
||||
## Issues and ideas
|
||||
## Asking Support Questions
|
||||
We have an active [discussion forum](https://github.com/navidrome/navidrome/discussions) where users and developers can ask questions. Please don't use the GitHub issue tracker to ask questions.
|
||||
|
||||
Anyone may report a suspected bug. Search existing [open and closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) and [discussions](https://github.com/navidrome/navidrome/discussions), and check the [documentation](https://www.navidrome.org/docs/) and [FAQ](https://www.navidrome.org/docs/faq/) first. Use the [bug report form](https://github.com/navidrome/navidrome/issues/new?template=bug_report.yml) for suspected defects and [Discussions → Ideas](https://github.com/navidrome/navidrome/discussions/categories/ideas) for new features or changes to intended behavior.
|
||||
## Code of Conduct
|
||||
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||
|
||||
Describe what happened, what you expected, and why that expectation should hold. Include reproduction steps where possible, with a reference to documentation, an applicable API contract, or a previously working version. If the problem is intermittent, explain what you observed and tried. Remove credentials and private information from logs and configuration.
|
||||
## Issues
|
||||
Found any issue or bug in our codebase? Have a great idea you want to propose or discuss with
|
||||
the developers? You can help by submitting an [issue](https://github.com/navidrome/navidrome/issues/new/choose)
|
||||
to the GitHub repository.
|
||||
|
||||
A reported problem may involve intended behavior, configuration, or a product decision. An open issue or a label does not establish that a change has been accepted. Report security vulnerabilities [privately through GitHub](https://github.com/navidrome/navidrome/security/advisories/new).
|
||||
**Before opening a new issue, please check if the issue has not been already made by searching
|
||||
the [issues](https://github.com/navidrome/navidrome/issues)**
|
||||
|
||||
## Agree on the change before implementation
|
||||
## Pull requests
|
||||
Before submitting a pull request, ensure that you go through the following:
|
||||
- Open a corresponding issue for the Pull Request, if not existing. The issue can be opened following [these guidelines](#issues)
|
||||
- Ensure that there is no open or closed Pull Request corresponding to your submission to avoid duplication of effort.
|
||||
- Setup the [development environment](https://www.navidrome.org/docs/developers/dev-environment/)
|
||||
- Create a new branch on your forked repo and make the changes in it. Naming conventions for branch are: `<Issue Title>/<Issue Number>`. Example:
|
||||
```
|
||||
git checkout -b adding-docs/834 master
|
||||
```
|
||||
- The commits should follow a [specific convention](#commit-conventions)
|
||||
- Ensure that a DCO sign-off for commits is provided via `--signoff` option of git commit
|
||||
- Provide a link to the issue that will be closed via your Pull request.
|
||||
|
||||
For code contributions beyond the small exceptions below, explain the problem, intended behavior, and smallest proposed scope in an existing issue or discussion. Wait for an explicit maintainer comment agreeing that the change should be pursued. Keep the discussion in one place. Silence, community support, and previous merged contributions do not replace that agreement.
|
||||
### Commit Conventions
|
||||
Each commit message must adhere to the following format:
|
||||
```
|
||||
<type>(scope): <description>
|
||||
|
||||
Small documentation corrections and small, unambiguous fixes that restore established behavior may be submitted directly. Provide evidence of the defect. These exceptions do not cover disputed behavior, new product choices, dependencies, or broader refactoring. If the intended behavior is unclear, discuss it first.
|
||||
[optional body]
|
||||
```
|
||||
This improves the readability of the messages
|
||||
|
||||
For large or cross-cutting changes, agree on the design and a sequence of small, reviewable phases before implementation. Explain why the change belongs in the core, what new complexity or dependencies it introduces, and which simpler alternatives you considered. Consider existing configuration, APIs, plugins, or external tools where appropriate. Splitting a change into PRs does not remove the need to agree on the overall design and scope.
|
||||
#### Type
|
||||
It can be one of the following:
|
||||
1. **feat**: Addition of a new feature
|
||||
2. **fix**: Bug fix
|
||||
3. **sec**: Fixing security issues
|
||||
4. **docs**: Documentation Changes
|
||||
5. **style**: Changes to styling
|
||||
6. **refactor**: Refactoring of code
|
||||
7. **perf**: Code that affects performance
|
||||
8. **test**: Updating or improving the current tests
|
||||
9. **build**: Changes to Build process
|
||||
10. **revert**: Reverting to a previous commit
|
||||
11. **chore** : updating grunt tasks etc
|
||||
|
||||
## Review your work before opening a PR
|
||||
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||
|
||||
Before opening any pull request, including a draft, you must personally read and review the entire diff and the relevant surrounding code, understand the implementation, and verify the affected behavior. This applies when an AI agent writes the code or creates the PR for you. An agent's summary or review does not satisfy this requirement. Personally review each subsequent update and verify its effects before pushing it or asking an agent to push it.
|
||||
#### Scope
|
||||
The file or folder where the changes are made. If there are more than one, you can mention any
|
||||
|
||||
Check the assumptions behind the change. Explain the implementation and important tradeoffs in your own words. For code changes, personally test the affected functionality, add regression tests where practical, and report the commands run and results actually observed. Identify failures, untested cases, and limitations. If an automated test is impractical, explain why and provide manual verification steps.
|
||||
#### Description
|
||||
A short description of the issue
|
||||
|
||||
Stay involved during review: answer questions, investigate feedback, and revise the work. If you need more time or cannot continue, say so. The maintainer is not committing to finish an abandoned implementation.
|
||||
#### Issue number
|
||||
The issue fixed by this Pull Request.
|
||||
|
||||
## AI Disclosure
|
||||
The body is optional. It may contain short description of changes made.
|
||||
|
||||
AI tools may assist professional and hobbyist contributors. All code contributors must be able to personally understand, review, test, and maintain their changes, including security and performance implications; no diploma, job title, or employment is required. If you cannot assess the code yourself, report the problem instead of submitting AI-generated code. Bug reports, documentation improvements, and translations remain welcome.
|
||||
Following all the guidelines an ideal commit will look like:
|
||||
```
|
||||
git commit --signoff -m "feat(themes): New-theme - #834"
|
||||
```
|
||||
|
||||
AI-assisted work must meet the personal full-diff review and functional testing requirements above before any PR is opened, including drafts or agent-created PRs. You remain responsible for explaining the implementation, addressing feedback, and correcting the code.
|
||||
After committing, push your commits to your forked branch and create a Pull Request from there.
|
||||
The Pull Request Title can be the same as `<type>(scope): <description> - <issue number>`
|
||||
A demo layout of how the Pull request body can look:
|
||||
```
|
||||
Closes <Issue number along with link>
|
||||
|
||||
Declare whether AI was used to generate or modify submitted code or tests, draft substantive PR text, or create the PR. A simple Yes or No in the PR description is enough. Tool names, prompts, and transcripts are not required.
|
||||
Description (What does the pull request do)
|
||||
|
||||
AI tools may help you draft, edit, or translate PR explanations and review replies. Review the final text before you send it, make sure you agree with it, and take responsibility for it. Do not send AI-generated text that you have not reviewed to maintainers or other contributors. Please be mindful of the maintainer's time.
|
||||
Changes (What changes were made )
|
||||
|
||||
## Keep the result maintainable
|
||||
Screenshots or Videos
|
||||
|
||||
Prefer existing patterns and the smallest clear solution. Justify new abstractions, dependencies, configuration, and compatibility obligations. Include the documentation and tests needed to understand the behavior and change it safely later.
|
||||
Related Issues and Pull Requests(if any)
|
||||
|
||||
The maintainer must be able to understand and maintain the result without depending on your future availability. An offer to provide ongoing support does not replace this requirement. A working implementation may still be declined if its benefit does not justify its complexity or maintenance cost.
|
||||
|
||||
<a id="pull-requests"></a>
|
||||
|
||||
## Submitting and review
|
||||
|
||||
- Check existing [open and closed PRs](https://github.com/navidrome/navidrome/pulls?q=is%3Apr) to avoid duplicate work. If the issue author or another contributor has offered to submit a fix, ask in the issue before you start.
|
||||
- Use the [development environment instructions](https://www.navidrome.org/docs/developers/dev-environment/). Work on a branch in your fork.
|
||||
- Complete the [PR template](.github/pull_request_template.md) with the scope agreement or why agreement is not needed, verification evidence, and maintenance implications. Keep each PR focused.
|
||||
- Follow the [commit conventions and DCO requirements](#commit-conventions-and-dco) below.
|
||||
|
||||
Review and merge are discretionary. Proposals may be declined, simplified, split, or deferred. PRs that do not fit Navidrome's direction, the agreed scope, or the maintainer's capacity to maintain them may be closed without detailed code review or prolonged discussion. PRs lacking evidence or left with unresolved questions may also be closed. Agreement to pursue work does not guarantee review or merge. Please do not tag the maintainer, send private messages, or post status requests to obtain faster review.
|
||||
|
||||
## Commit conventions and DCO
|
||||
|
||||
Use this commit format: `<type>(scope): <description>`, with an optional body.
|
||||
|
||||
Allowed types: `feat`, `fix`, `sec`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `revert`, `chore`.
|
||||
|
||||
Use a relevant file or folder as the scope and a concise description. Reference the related issue when one exists, and include `BREAKING CHANGE` in the body for breaking changes. Sign off every commit using `git commit --signoff`. These requirements apply equally to AI-assisted work.
|
||||
```
|
||||
|
|
|
|||
47
Dockerfile
47
Dockerfile
|
|
@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM ghcr.io/crazy-max/osxcross:14.5-debian AS osxcros
|
|||
|
||||
########################################################################################################################
|
||||
### Build xx (original image: tonistiigi/xx)
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS xx-build
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/alpine:3.20 AS xx-build
|
||||
|
||||
# v1.9.0
|
||||
ENV XX_VERSION=a5592eab7a57895e8d385394ff12241bc65ecd50
|
||||
|
|
@ -26,7 +26,7 @@ COPY --from=xx-build /out/ /usr/bin/
|
|||
|
||||
########################################################################################################################
|
||||
### 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
|
||||
|
||||
# Install node dependencies
|
||||
|
|
@ -43,7 +43,7 @@ COPY --from=ui /build /build
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for Docker image (dynamic musl, enables native libwebp via dlopen)
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build-alpine
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-alpine AS build-alpine
|
||||
COPY --from=xx / /
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
|
@ -85,7 +85,7 @@ EOT
|
|||
|
||||
########################################################################################################################
|
||||
### Build Navidrome binary for standalone distribution (static glibc, cross-compiled)
|
||||
FROM --platform=$BUILDPLATFORM golang:1.27-trixie AS base
|
||||
FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.26-trixie AS base
|
||||
RUN apt-get update && apt-get install -y clang lld
|
||||
COPY --from=xx / /
|
||||
WORKDIR /workspace
|
||||
|
|
@ -152,52 +152,19 @@ RUN xx-verify --static /out/navidrome*
|
|||
FROM scratch AS binary
|
||||
COPY --from=build /out /
|
||||
|
||||
########################################################################################################################
|
||||
### Build no-op stubs for mpv's video-output libraries
|
||||
# mpv links libEGL/libgbm for video output only; Navidrome drives it headless, for audio.
|
||||
# Real mesa pulls in LLVM + gallium (+218MB uncompressed), so ship stubs it never calls.
|
||||
FROM --platform=$BUILDPLATFORM alpine:3.22 AS mpv-stubs
|
||||
COPY --from=xx / /
|
||||
RUN apk add --no-cache clang lld binutils mesa-egl mesa-gbm
|
||||
ARG TARGETPLATFORM
|
||||
RUN xx-apk add --no-cache musl-dev
|
||||
RUN <<EOT
|
||||
set -e
|
||||
mkdir -p /out
|
||||
for so in libEGL.so.1 libgbm.so.1; do
|
||||
readelf -sW /usr/lib/$so \
|
||||
| awk '$5 == "GLOBAL" && $7 != "UND" { print $8 }' \
|
||||
| sed 's/@.*//' \
|
||||
| grep -vE '^(_init|_fini|_edata|_end|__bss_start|_GLOBAL_OFFSET_TABLE_)$' \
|
||||
| sort -u \
|
||||
| awk '{ print "void " $1 "(void) {}" }' > /tmp/stub.c
|
||||
test -s /tmp/stub.c
|
||||
xx-clang -shared -nostdlib -fPIC -Wl,-soname,$so -o /out/$so /tmp/stub.c
|
||||
xx-verify /out/$so
|
||||
done
|
||||
EOT
|
||||
|
||||
########################################################################################################################
|
||||
### Build Final Image
|
||||
FROM alpine:3.22 AS final
|
||||
FROM public.ecr.aws/docker/library/alpine:3.20 AS final
|
||||
LABEL maintainer="deluan@navidrome.org"
|
||||
LABEL org.opencontainers.image.source="https://github.com/navidrome/navidrome"
|
||||
|
||||
# Install runtime dependencies
|
||||
# - libwebp + symlinks: enables native WebP encoding via purego/dlopen
|
||||
# The mesa/LLVM stack mpv pulls in for video output is dropped in this same layer,
|
||||
# otherwise the deleted bytes still ship in the image.
|
||||
RUN apk add -U --no-cache curl ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
RUN apk add -U --no-cache ffmpeg mpv sqlite libwebp libwebpdemux libwebpmux && \
|
||||
for lib in libwebp libwebpdemux libwebpmux; do \
|
||||
target=$(ls /usr/lib/$lib.so.* 2>/dev/null | head -1) && \
|
||||
[ -n "$target" ] && ln -sf "$target" /usr/lib/$lib.so; \
|
||||
done && \
|
||||
rm -rf /usr/lib/gallium-pipe /usr/lib/dri \
|
||||
/usr/lib/libEGL.so* /usr/lib/libgbm.so* /usr/lib/libgallium*.so /usr/lib/libLLVM.so* \
|
||||
/usr/lib/libGL.so* /usr/lib/libGLESv2.so* /usr/lib/libglapi.so*
|
||||
|
||||
COPY --from=mpv-stubs /out/ /usr/lib/
|
||||
RUN mpv --no-video --ao=null --version > /dev/null
|
||||
done
|
||||
|
||||
# Copy navidrome binary (musl build for Docker, enables native libwebp)
|
||||
COPY --from=build-alpine /out/navidrome /app/
|
||||
|
|
|
|||
51
Makefile
51
Makefile
|
|
@ -18,13 +18,9 @@ endif
|
|||
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||
DOCKER_TAG ?= navidrome/navidrome:develop
|
||||
DOCKER_TAG ?= deluan/navidrome:develop
|
||||
|
||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||
VACUUM_VERSION ?= v0.30.6
|
||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
||||
OASDIFF_VERSION ?= v1.32.1
|
||||
API_DIFF_BASE ?= origin/master
|
||||
GOLANGCI_LINT_VERSION ?= v2.12.0
|
||||
|
||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||
|
||||
|
|
@ -96,45 +92,6 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
|||
fi
|
||||
.PHONY: install-golangci-lint
|
||||
|
||||
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
||||
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
||||
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
||||
echo "Installing OpenAPI tools..."; \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
||||
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
||||
rm -f bin/.api-tools-* && touch $$STAMP; \
|
||||
fi
|
||||
.PHONY: install-api-tools
|
||||
|
||||
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
||||
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
||||
.PHONY: api-lint
|
||||
|
||||
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
||||
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
||||
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
||||
.PHONY: api-bundle
|
||||
|
||||
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
||||
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
||||
.PHONY: api-gen
|
||||
|
||||
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
||||
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
||||
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
||||
if [ -z "$$BASE" ]; then \
|
||||
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
||||
BASE=$(API_DIFF_BASE); \
|
||||
fi; \
|
||||
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
||||
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
||||
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
||||
else \
|
||||
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
||||
fi
|
||||
.PHONY: api-diff
|
||||
|
||||
lint: install-golangci-lint ##@Development Lint Go code
|
||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||
.PHONY: lint
|
||||
|
|
@ -154,7 +111,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
|||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||
.PHONY: wire
|
||||
|
||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
||||
gen: check_go_env ##@Development Run go generate for code generation
|
||||
go generate ./...
|
||||
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
|
||||
|
|
@ -223,7 +180,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
|||
--output "./binaries" --target binary .
|
||||
.PHONY: docker-build
|
||||
|
||||
docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
||||
docker-image: ##@Cross_Compilation Build Docker image, tagged as `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
||||
@echo $(IMAGE_PLATFORMS) | grep -q "windows" && echo "ERROR: Windows is not supported for Docker builds" && exit 1 || true
|
||||
@echo $(IMAGE_PLATFORMS) | grep -q "darwin" && echo "ERROR: macOS is not supported for Docker builds" && exit 1 || true
|
||||
@echo $(IMAGE_PLATFORMS) | grep -q "arm/v5" && echo "ERROR: Linux ARMv5 is not supported for Docker builds" && exit 1 || true
|
||||
|
|
|
|||
25
README.md
25
README.md
|
|
@ -5,7 +5,7 @@
|
|||
[](https://github.com/navidrome/navidrome/releases)
|
||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||
[](https://hub.docker.com/r/navidrome/navidrome)
|
||||
[](https://hub.docker.com/r/deluan/navidrome)
|
||||
[](https://discord.gg/xh7j7yF)
|
||||
[](https://www.reddit.com/r/navidrome/)
|
||||
[](CODE_OF_CONDUCT.md)
|
||||
|
|
@ -21,11 +21,9 @@ the `master` branch in order to get a stable set of binaries.
|
|||
|
||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
||||
|
||||
__Any feedback is welcome!__ If you find a bug, please file a [GitHub issue](https://github.com/navidrome/navidrome/issues/new/choose).
|
||||
If you need/want a new feature or think of any way to improve Navidrome, please share it in
|
||||
[Discussions](https://github.com/navidrome/navidrome/discussions/categories/ideas) or join the discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). Before contributing code, please read the
|
||||
[Contribution Guide](CONTRIBUTING.md). If you want to contribute to the project in any other way
|
||||
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
||||
|
|
@ -37,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
|||
|
||||
## 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.
|
||||
|
||||
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
|
||||
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||
|
||||
[](https://www.pikapods.com/pods?run=navidrome)
|
||||
|
||||
## Features
|
||||
|
||||
|
|
|
|||
|
|
@ -13,26 +13,15 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const apiBaseURL = "https://api.deezer.com"
|
||||
const authBaseURL = "https://auth.deezer.com"
|
||||
|
||||
// errCodeQuota is Deezer's "Quota limit exceeded"; it arrives in the body, with HTTP 200
|
||||
// and no rate-limit headers, so the body code is the only signal.
|
||||
const errCodeQuota = 4
|
||||
|
||||
type deezerError struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
}
|
||||
|
||||
func (e *deezerError) Error() string {
|
||||
return fmt.Sprintf("deezer error(%d): %s", e.Code, e.Message)
|
||||
}
|
||||
var (
|
||||
ErrNotFound = errors.New("deezer: not found")
|
||||
)
|
||||
|
||||
type httpDoer interface {
|
||||
Do(req *http.Request) (*http.Response, error)
|
||||
|
|
@ -67,7 +56,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
|
|||
}
|
||||
|
||||
if len(results.Data) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return results.Data, nil
|
||||
}
|
||||
|
|
@ -85,31 +74,20 @@ func (c *client) makeRequest(req *http.Request, response any) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Checked before the status: a throttled request still answers 200, and decoding its body
|
||||
// into a result type yields an empty one, which reads as "nothing found".
|
||||
if err := parseBodyError(data); err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("deezer http status: (%d)", resp.StatusCode)
|
||||
return c.parseError(data)
|
||||
}
|
||||
|
||||
return json.Unmarshal(data, response)
|
||||
}
|
||||
|
||||
// parseBodyError returns the error Deezer reported in the body, or nil when it reported none.
|
||||
func parseBodyError(data []byte) error {
|
||||
var body errorResponse
|
||||
// Discarded: a payload that is not an error object leaves Error nil, which is the "none" answer.
|
||||
_ = json.Unmarshal(data, &body)
|
||||
switch {
|
||||
case body.Error == nil:
|
||||
return nil
|
||||
case body.Error.Code == errCodeQuota:
|
||||
return errors.Join(body.Error, agents.ErrRetryLater)
|
||||
default:
|
||||
return body.Error
|
||||
func (c *client) parseError(data []byte) error {
|
||||
var deezerError Error
|
||||
err := json.Unmarshal(data, &deezerError)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
|
||||
}
|
||||
|
||||
func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ package deezer
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
|
@ -43,37 +41,7 @@ var _ = Describe("client", func() {
|
|||
})
|
||||
|
||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(MatchError(agents.ErrNotFound))
|
||||
})
|
||||
|
||||
// Deezer answers 200 with no rate-limit headers when throttling, so this body is the only signal.
|
||||
It("reports an exhausted quota as a retryable error, not as a missing artist", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(
|
||||
`{"error":{"type":"Exception","message":"Quota limit exceeded","code":4}}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).ToNot(MatchError(agents.ErrNotFound),
|
||||
"a throttled lookup would otherwise settle the artist as having no image")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
Expect(err.Error()).To(ContainSubstring("Quota limit exceeded"))
|
||||
})
|
||||
|
||||
It("reports a non-quota body error as a plain error", func() {
|
||||
httpClient.mock("https://api.deezer.com/search/artist", http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(
|
||||
`{"error":{"type":"Exception","message":"Invalid query","code":100}}`)),
|
||||
})
|
||||
|
||||
_, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err).ToNot(MatchError(agents.ErrNotFound))
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse(),
|
||||
"only a throttle asks the caller to come back later")
|
||||
Expect(err).To(MatchError(ErrNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
|
|
@ -14,7 +15,6 @@ import (
|
|||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -36,7 +36,9 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
|||
dataStore: dataStore,
|
||||
languages: conf.Server.Deezer.Languages,
|
||||
}
|
||||
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
httpClient := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||
agent.client = newClient(cachedHttpClient)
|
||||
return agent
|
||||
|
|
@ -91,6 +93,9 @@ func isPlaceholderPicture(url string) bool {
|
|||
|
||||
func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, error) {
|
||||
artists, err := s.client.searchArtists(ctx, name, deezerArtistSearchLimit)
|
||||
if errors.Is(err, ErrNotFound) || len(artists) == 0 {
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package deezer
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
|
@ -81,22 +80,6 @@ var _ = Describe("deezerAgent", func() {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -22,8 +22,12 @@ type Artist struct {
|
|||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error *deezerError `json:"error"`
|
||||
type Error struct {
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
Code int `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type RelatedArtists struct {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ var _ = Describe("Responses", func() {
|
|||
|
||||
Describe("Error", func() {
|
||||
It("parses the error response correctly", func() {
|
||||
var errorResp errorResponse
|
||||
var errorResp Error
|
||||
body := []byte(`{"error":{"type":"MissingParameterException","message":"Missing parameters: q","code":501}}`)
|
||||
err := json.Unmarshal(body, &errorResp)
|
||||
Expect(err).To(BeNil())
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
|
|||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||
|
||||
metadata := metadata.New(path, info)
|
||||
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
||||
return new(metadata.ToMediaFile(1, "folderID"))
|
||||
}
|
||||
|
||||
BeforeEach(func() {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/utils/cache"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
|
|
@ -60,7 +59,9 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
|||
secret: conf.Server.LastFM.Secret,
|
||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.httpClient = chc
|
||||
l.client = newClient(l.apiKey, l.secret, chc)
|
||||
|
|
@ -92,7 +93,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
|||
var resp agents.AlbumInfo
|
||||
for _, lang := range l.languages {
|
||||
var err error
|
||||
a, err = l.callAlbumGetInfo(ctx, name, artist, lang)
|
||||
a, err = l.callAlbumGetInfo(ctx, name, artist, mbid, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -113,7 +114,7 @@ func (l *lastfmAgent) GetAlbumInfo(ctx context.Context, name, artist, mbid strin
|
|||
}
|
||||
|
||||
func (l *lastfmAgent) GetAlbumImages(ctx context.Context, name, artist, mbid string) ([]agents.ExternalImage, error) {
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, l.languages[0])
|
||||
a, err := l.callAlbumGetInfo(ctx, name, artist, mbid, l.languages[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -241,10 +242,6 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
|||
var (
|
||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||
|
||||
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
|
||||
// methods, which the page block does not affect.
|
||||
errNoArtistPage = errors.New("no artist image in Last.fm page")
|
||||
)
|
||||
|
||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||
|
|
@ -271,9 +268,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
|||
var res []agents.ExternalImage
|
||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||
if n == nil {
|
||||
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
|
||||
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
|
||||
return nil, errNoArtistPage
|
||||
return res, nil
|
||||
}
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key != "content" {
|
||||
|
|
@ -291,18 +286,22 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
|||
return res, nil
|
||||
}
|
||||
|
||||
// callAlbumGetInfo matches on name+artist only. Last.fm's album.getInfo by MBID is unreliable —
|
||||
// a correct MBID can return a different album (or none) — so the MBID is deliberately not passed.
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, lang string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, "", lang)
|
||||
func (l *lastfmAgent) callAlbumGetInfo(ctx context.Context, name, artist, mbid string, lang string) (*Album, error) {
|
||||
a, err := l.client.albumGetInfo(ctx, name, artist, mbid, lang)
|
||||
var lfErr *lastFMError
|
||||
isLastFMError := errors.As(err, &lfErr)
|
||||
|
||||
if mbid != "" && (isLastFMError && lfErr.Code == 6) {
|
||||
log.Debug(ctx, "LastFM/album.getInfo could not find album by mbid, trying again", "album", name, "mbid", mbid)
|
||||
return l.callAlbumGetInfo(ctx, name, artist, "", lang)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
|
||||
// A not-found is a definitive absence, not a fault: return the shared sentinel so the
|
||||
// artwork worker's breaker/transient checks don't retry it, and log it at Debug.
|
||||
log.Debug(ctx, "Album not found in Last.fm", "album", name, "artist", artist)
|
||||
return nil, agents.ErrNotFound
|
||||
if isLastFMError && lfErr.Code == 6 {
|
||||
log.Debug(ctx, "Album not found", "album", name, "mbid", mbid, err)
|
||||
} else {
|
||||
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "mbid", mbid, err)
|
||||
}
|
||||
log.Error(ctx, "Error calling LastFM/album.getInfo", "album", name, "artist", artist, err)
|
||||
return nil, err
|
||||
}
|
||||
return a, nil
|
||||
|
|
@ -314,12 +313,6 @@ func (l *lastfmAgent) callArtistGetInfo(ctx context.Context, name string, lang s
|
|||
|
||||
a, err := l.client.artistGetInfo(ctx, name, lang)
|
||||
if err != nil {
|
||||
if lfErr, ok := errors.AsType[*lastFMError](err); ok && lfErr.Code == 6 {
|
||||
// A not-found is a definitive absence, not a fault: return the shared sentinel so it
|
||||
// doesn't trip the artwork worker's breaker, and log at Debug instead of Error.
|
||||
log.Debug(ctx, "Artist not found in Last.fm", "artist", name)
|
||||
return nil, agents.ErrNotFound
|
||||
}
|
||||
log.Error(ctx, "Error calling LastFM/artist.getInfo", "artist", name, err)
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -411,8 +404,7 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
|
|||
log.Warn(ctx, "Last.fm client.scrobble returned error", "track", s.Title, err)
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
// 11: service offline; 16: temporarily unavailable. Rate limiting is mapped by the client.
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 || errors.Is(err, scrobbler.ErrRetryLater) {
|
||||
if lfErr.Code == 11 || lfErr.Code == 16 {
|
||||
return errors.Join(err, scrobbler.ErrRetryLater)
|
||||
}
|
||||
return errors.Join(err, scrobbler.ErrUnrecoverable)
|
||||
|
|
|
|||
|
|
@ -100,15 +100,6 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("U2"))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
_, err := agent.GetArtistBiography(ctx, "123", "U2", "")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Language Fallback", func() {
|
||||
|
|
@ -357,7 +348,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
var httpClient *tests.FakeHttpClient
|
||||
var track *model.MediaFile
|
||||
BeforeEach(func() {
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
client := newClient("API_KEY", "SECRET", httpClient)
|
||||
agent = lastFMConstructor(ds)
|
||||
|
|
@ -506,16 +497,6 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(err).To(MatchError(scrobbler.ErrRetryLater))
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on error 29 (rate limit exceeded)", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"error":29,"message":"Rate limit exceeded"}`)),
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
err := agent.Scrobble(ctx, "user-1", scrobbler.Scrobble{MediaFile: *track, TimeStamp: time.Now()})
|
||||
Expect(errors.Is(err, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater on http errors", func() {
|
||||
httpClient.Res = http.Response{
|
||||
Body: io.NopCloser(bytes.NewBufferString(`internal server error`)),
|
||||
|
|
@ -558,10 +539,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
URL: "https://www.last.fm/music/Cher/Believe",
|
||||
}))
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
// MBID is deliberately not sent — album.getInfo matches on name+artist only.
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("album")).To(Equal("Believe"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Cher"))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("03c91c40-49a6-44a7-90e7-a700edf97a62"))
|
||||
})
|
||||
|
||||
It("returns empty images if no images are available", func() {
|
||||
|
|
@ -580,7 +558,7 @@ var _ = Describe("lastfmAgent", func() {
|
|||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error if Last.fm call returns an error", func() {
|
||||
|
|
@ -588,17 +566,23 @@ var _ = Describe("lastfmAgent", func() {
|
|||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(Equal("mbid-1234"))
|
||||
})
|
||||
|
||||
It("returns an error when Last.fm returns an error 6 (album not found)", func() {
|
||||
It("returns an error if Last.fm call returns an error 6 and mbid is empty", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
_, err := agent.GetAlbumInfo(ctx, "123", "U2", "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
// A definitive not-found must satisfy the sentinel, or the artwork worker retries it.
|
||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
||||
Expect(httpClient.RequestCount).To(Equal(1))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
|
||||
Context("MBID non existent in Last.fm", func() {
|
||||
It("calls again when last.fm returns an error 6", func() {
|
||||
httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, _ = agent.GetAlbumInfo(ctx, "123", "U2", "mbid-1234")
|
||||
Expect(httpClient.RequestCount).To(Equal(2))
|
||||
Expect(httpClient.SavedRequest.URL.Query().Get("mbid")).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -629,13 +613,6 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
|
||||
})
|
||||
|
||||
It("maps a Last.fm error 6 (artist not found) to the shared not-found sentinel", func() {
|
||||
apiClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString(lastfmError6)), StatusCode: 200}
|
||||
_, err := agent.GetArtistImages(ctx, "123", "Nonexistent Artist", "")
|
||||
// Not a fault: runs of missing artists must not trip the worker's circuit breaker.
|
||||
Expect(errors.Is(err, agents.ErrNotFound)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns empty list if image is the ignored default image", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
|
@ -648,41 +625,18 @@ var _ = Describe("lastfmAgent", func() {
|
|||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("errors when the page has no meta tags", func() {
|
||||
It("returns empty list if page has no meta tags", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).To(MatchError(errNoArtistPage))
|
||||
})
|
||||
|
||||
It("errors when Last.fm serves a bot challenge page", func() {
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(err).To(MatchError(errNoArtistPage))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(images).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("does not park the agent: the failure is not a retry-later", func() {
|
||||
// A RetryLaterError would cool down the agent's API-backed methods too.
|
||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||
|
||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns error if API call fails", func() {
|
||||
apiClient.Err = errors.New("api error")
|
||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
"github.com/navidrome/navidrome/utils/req"
|
||||
)
|
||||
|
||||
|
|
@ -42,7 +41,9 @@ func NewRouter(ds model.DataStore) *Router {
|
|||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = newClient(r.apiKey, r.secret, hc)
|
||||
return r
|
||||
}
|
||||
|
|
@ -132,7 +133,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
|||
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||
sessionKey, err := s.client.getSession(ctx, token)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
|
||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||
"requestId", middleware.GetReqID(ctx), err)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
|||
})
|
||||
|
||||
storedSessionKey := func(userID string) string {
|
||||
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||
return key
|
||||
}
|
||||
|
||||
|
|
@ -214,14 +214,5 @@ var _ = Describe("auth_router", func() {
|
|||
_, err = verifyLinkToken(nonExpiringToken)
|
||||
Expect(err).To(MatchError("link token missing expiration"))
|
||||
})
|
||||
|
||||
It("rejects a Jellyfin access token", func() {
|
||||
usr := &model.User{ID: "u1", UserName: "johndoe"}
|
||||
tokenStr, err := auth.CreateAPIToken(usr, auth.AudienceJellyfin)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
_, err = verifyLinkToken(tokenStr)
|
||||
Expect(err).To(HaveOccurred())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -15,15 +14,11 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseUrl = "https://ws.audioscrobbler.com/2.0/"
|
||||
// errCodeRateLimit is Last.fm's "rate limit exceeded"; it arrives in the body, with HTTP 200
|
||||
// and no rate-limit headers, so the body code is the only signal.
|
||||
errCodeRateLimit = 29
|
||||
)
|
||||
|
||||
type lastFMError struct {
|
||||
|
|
@ -49,12 +44,6 @@ type client struct {
|
|||
hc httpDoer
|
||||
}
|
||||
|
||||
// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space.
|
||||
// album.getInfo decodes only once, so it must not use this.
|
||||
func escapePlus(s string) string {
|
||||
return strings.ReplaceAll(s, "+", "%2B")
|
||||
}
|
||||
|
||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "album.getInfo")
|
||||
|
|
@ -72,7 +61,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
|
|||
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getInfo")
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("artist", name)
|
||||
params.Add("lang", lang)
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -84,7 +73,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*
|
|||
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getSimilar")
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("artist", name)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -96,7 +85,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (
|
|||
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "artist.getTopTracks")
|
||||
params.Add("artist", escapePlus(name))
|
||||
params.Add("artist", name)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -108,8 +97,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
|
|||
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
||||
params := url.Values{}
|
||||
params.Add("method", "track.getSimilar")
|
||||
params.Add("track", escapePlus(name))
|
||||
params.Add("artist", escapePlus(artist))
|
||||
params.Add("track", name)
|
||||
params.Add("artist", artist)
|
||||
params.Add("limit", strconv.Itoa(limit))
|
||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||
if err != nil {
|
||||
|
|
@ -236,11 +225,7 @@ func (c *client) makeRequest(ctx context.Context, method string, params url.Valu
|
|||
return nil, jsonErr
|
||||
}
|
||||
if response.Error != 0 {
|
||||
var err error = &lastFMError{Code: response.Error, Message: response.Message}
|
||||
if response.Error == errCodeRateLimit {
|
||||
err = errors.Join(err, &agents.RetryLaterError{})
|
||||
}
|
||||
return &response, err
|
||||
return &response, &lastFMError{Code: response.Error, Message: response.Message}
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
|
|
|||
|
|
@ -35,15 +35,6 @@ var _ = Describe("client", func() {
|
|||
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() {
|
||||
|
|
@ -57,15 +48,6 @@ var _ = Describe("client", func() {
|
|||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
||||
})
|
||||
|
||||
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`)),
|
||||
|
|
@ -125,15 +107,6 @@ var _ = Describe("client", func() {
|
|||
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() {
|
||||
|
|
@ -146,15 +119,6 @@ var _ = Describe("client", func() {
|
|||
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() {
|
||||
|
|
@ -171,17 +135,6 @@ var _ = Describe("client", func() {
|
|||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
||||
})
|
||||
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package listenbrainz
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -11,7 +12,6 @@ import (
|
|||
"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"
|
||||
)
|
||||
|
||||
|
|
@ -33,7 +33,9 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
|||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||
}
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||
l.client = newClient(l.baseURL, chc)
|
||||
return l
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
BeforeEach(func() {
|
||||
ds = &tests.MockDataStore{}
|
||||
ctx = context.Background()
|
||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||
httpClient = &tests.FakeHttpClient{}
|
||||
agent = listenBrainzConstructor(ds)
|
||||
agent.client = newClient("http://localhost:8080", httpClient)
|
||||
|
|
@ -164,19 +164,6 @@ var _ = Describe("listenBrainzAgent", func() {
|
|||
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() {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import (
|
|||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/model/request"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/utils/httpclient"
|
||||
)
|
||||
|
||||
type sessionKeysRepo interface {
|
||||
|
|
@ -38,7 +37,9 @@ func NewRouter(ds model.DataStore) *Router {
|
|||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||
}
|
||||
r.Handler = r.routes()
|
||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||
hc := &http.Client{
|
||||
Timeout: consts.DefaultHttpClientTimeOut,
|
||||
}
|
||||
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||
return r
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import (
|
|||
"slices"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
)
|
||||
|
||||
|
|
@ -22,12 +21,6 @@ const (
|
|||
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")
|
||||
)
|
||||
|
|
@ -181,9 +174,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
|||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
return nil, retryLaterErr(resp.Header)
|
||||
}
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
|
||||
var response listenBrainzResponse
|
||||
|
|
@ -195,10 +185,6 @@ func (c *client) makeAuthenticatedRequest(ctx context.Context, method string, en
|
|||
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}
|
||||
}
|
||||
|
||||
|
|
@ -225,9 +211,6 @@ func (c *client) makeGenericRequest(ctx context.Context, method string, endpoint
|
|||
// On a 200 code, there is no code. Decode using using error message if it exists
|
||||
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
|
||||
|
|
|
|||
|
|
@ -4,17 +4,13 @@ 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"
|
||||
|
|
@ -465,73 +461,4 @@ var _ = Describe("client", func() {
|
|||
}))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("rate limiting", func() {
|
||||
It("returns RetryLaterError with the header delay on 429", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"3"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(3 * time.Second))
|
||||
})
|
||||
|
||||
It("returns RetryLaterError with zero delay when no header is present", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(retry.RetryIn).To(BeZero())
|
||||
})
|
||||
|
||||
DescribeTable("caps absurd header values at one hour",
|
||||
func(header string) {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{header}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
retry, _ := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(retry.RetryIn).To(Equal(time.Hour))
|
||||
},
|
||||
Entry("a large value", "999999"),
|
||||
Entry("a huge value", "99999999999"),
|
||||
// Scaling this to nanoseconds before capping wraps past 2^64, landing on ~0.29s.
|
||||
Entry("a value that overflows int64 nanoseconds", "18446744074"),
|
||||
)
|
||||
|
||||
It("maps a body-level 429 sent with a non-429 status", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"7"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"You have exceeded your rate limit."}`)),
|
||||
}
|
||||
_, err := client.validateToken(context.Background(), "token")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(7 * time.Second))
|
||||
})
|
||||
|
||||
It("returns RetryLaterError on a 429 from makeGenericRequest", func() {
|
||||
httpClient.Res = http.Response{
|
||||
StatusCode: 429,
|
||||
Header: http.Header{"X-Ratelimit-Reset-In": []string{"5"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"code":429,"error":"rate limited"}`)),
|
||||
}
|
||||
_, err := client.getArtistUrl(context.Background(), "1")
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(5 * time.Second))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -12,11 +14,10 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
backupCount int
|
||||
backupDir string
|
||||
force bool
|
||||
integrityCheck bool
|
||||
restorePath string
|
||||
backupCount int
|
||||
backupDir string
|
||||
force bool
|
||||
restorePath string
|
||||
)
|
||||
|
||||
func init() {
|
||||
|
|
@ -30,9 +31,8 @@ func init() {
|
|||
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
||||
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(&integrityCheck, "integrity-check", "i", false, "check the backup file with a full integrity check before restoring. Finds more kinds of damage, but takes longer")
|
||||
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||
backupRoot.AddCommand(restoreCommand)
|
||||
}
|
||||
|
|
@ -78,12 +78,24 @@ func runBackup(ctx context.Context) {
|
|||
conf.Server.Backup.Path = conf.NewDir(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()
|
||||
path, err := db.Backup(ctx)
|
||||
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)
|
||||
|
|
@ -99,17 +111,36 @@ func runPrune(ctx context.Context) {
|
|||
conf.Server.Backup.Count = backupCount
|
||||
}
|
||||
|
||||
if conf.Server.Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
||||
log.Warn("Prune cancelled")
|
||||
return
|
||||
if conf.Server.Backup.Count == 0 && !force {
|
||||
fmt.Println("Warning: pruning ALL backups")
|
||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||
var input string
|
||||
_, err := fmt.Scanln(&input)
|
||||
|
||||
if input != "YES" || err != nil {
|
||||
log.Warn("Prune cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
count, err := db.Prune(ctx)
|
||||
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)
|
||||
|
|
@ -118,29 +149,36 @@ func runPrune(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
|
||||
// `backup create` writes to. Without this, the value was treated as relative
|
||||
// to the working directory, where the file does not exist.
|
||||
if !filepath.IsAbs(restorePath) {
|
||||
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 idx == -1 {
|
||||
path = conf.Server.DbPath
|
||||
} else {
|
||||
path = conf.Server.DbPath[:idx]
|
||||
}
|
||||
|
||||
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
|
||||
log.Warn("Restore cancelled")
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
log.Fatal("No existing database", "path", path)
|
||||
return
|
||||
}
|
||||
|
||||
if !force {
|
||||
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
|
||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||
var input string
|
||||
_, err := fmt.Scanln(&input)
|
||||
|
||||
if input != "YES" || err != nil {
|
||||
log.Warn("Restore cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
err := db.Restore(ctx, restorePath, integrityCheck)
|
||||
err := db.Restore(ctx, restorePath)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -32,7 +28,7 @@ var inspectCmd = &cobra.Command{
|
|||
Long: "Show file tags as seen by Navidrome",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
runInspector(cmd.Context(), args)
|
||||
runInspector(args)
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
|
|||
return []byte(res.String()), nil
|
||||
}
|
||||
|
||||
func runInspector(ctx context.Context, args []string) {
|
||||
func runInspector(args []string) {
|
||||
marshal := marshalers[format]
|
||||
if marshal == nil {
|
||||
log.Fatal("Invalid format", "format", format)
|
||||
}
|
||||
libs := loadLibraries(ctx)
|
||||
matcher := model.NewLibraryMatcher(libs)
|
||||
var out []core.InspectOutput
|
||||
for _, filePath := range args {
|
||||
if !model.IsAudioFile(filePath) {
|
||||
log.Warn("Not an audio file", "file", filePath)
|
||||
continue
|
||||
}
|
||||
lib, ok := libraryForFile(matcher, filePath)
|
||||
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, "")
|
||||
output, err := core.Inspect(filePath, 1, "")
|
||||
if err != nil {
|
||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||
continue
|
||||
|
|
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
|
|||
data, _ := marshal(out)
|
||||
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))
|
||||
})
|
||||
})
|
||||
31
cmd/pls.go
31
cmd/pls.go
|
|
@ -6,14 +6,13 @@ import (
|
|||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/core/artwork"
|
||||
"github.com/navidrome/navidrome/core"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -109,7 +108,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
|||
}
|
||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||
}
|
||||
pls, err := ds.Playlist().GetAll(ctx, options)
|
||||
pls, err := ds.Playlist(ctx).GetAll(options)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||
}
|
||||
|
|
@ -117,17 +116,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
|||
}
|
||||
|
||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
||||
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
||||
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
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": nameOrID}})
|
||||
if err != nil {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
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 {
|
||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||
}
|
||||
|
|
@ -142,16 +141,14 @@ func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *mod
|
|||
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)
|
||||
pls := playlist.ToM3U8()
|
||||
if outputFile == "-" || outputFile == "" {
|
||||
println(pls)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(file, []byte(m3u), 0600); err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", file, err)
|
||||
err := os.WriteFile(outputFile, []byte(pls), 0600)
|
||||
if err != nil {
|
||||
log.Fatal("Error writing to the output file", "file", outputFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +157,7 @@ func runExport(ctx context.Context) {
|
|||
|
||||
if playlistID != "" && outputFile == "" {
|
||||
playlist := findPlaylist(ctx, ds, playlistID)
|
||||
writePlaylist(playlist.ToM3U8(), os.Stdout, outputFile)
|
||||
println(playlist.ToM3U8())
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +191,7 @@ func runExport(ctx context.Context) {
|
|||
|
||||
exported := 0
|
||||
for _, pls := range allPls {
|
||||
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
||||
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
||||
if err != nil {
|
||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||
continue
|
||||
|
|
@ -263,7 +260,7 @@ func runImport(ctx context.Context, files []string) {
|
|||
ctx = request.WithUser(ctx, *user)
|
||||
}
|
||||
|
||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService(ds))
|
||||
|
||||
for _, file := range files {
|
||||
absPath, err := filepath.Abs(file)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -243,7 +244,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
|
|
@ -264,7 +265,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
|||
}
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
p, err := ds.Plugin().Get(ctx, arg)
|
||||
p, err := ds.Plugin(ctx).Get(arg)
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||
}
|
||||
|
|
@ -313,7 +314,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
|||
return sb.String(), w.Error()
|
||||
case "table":
|
||||
var sb strings.Builder
|
||||
w := newTabWriter(&sb)
|
||||
w := tabwriter.NewWriter(&sb, 0, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "ID\tNAME\tVERSION\tENABLED\tLAST ERROR")
|
||||
for _, p := range list {
|
||||
name, version := manifestSummary(p)
|
||||
|
|
@ -329,7 +330,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
|||
func runPluginList(ctx context.Context) {
|
||||
requirePluginsEnabled(ctx)
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
list, err := ds.Plugin().GetAll(ctx)
|
||||
list, err := ds.Plugin(ctx).GetAll()
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to list plugins", err)
|
||||
}
|
||||
|
|
@ -372,7 +373,7 @@ var pluginEditCmd = &cobra.Command{
|
|||
Run: func(cmd *cobra.Command, args []string) {
|
||||
requirePluginsEnabled(cmd.Context())
|
||||
ds, ctx := getAdminContext(cmd.Context())
|
||||
cur, err := ds.Plugin().Get(ctx, args[0])
|
||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||
}
|
||||
|
|
|
|||
139
cmd/root.go
139
cmd/root.go
|
|
@ -2,7 +2,6 @@ package cmd
|
|||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
|
|
@ -44,9 +43,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
|||
preRun()
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := runNavidrome(cmd.Context()); err != nil {
|
||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
runNavidrome(cmd.Context())
|
||||
},
|
||||
PostRun: func(cmd *cobra.Command, args []string) {
|
||||
postRun()
|
||||
|
|
@ -78,17 +75,16 @@ func postRun() {
|
|||
}
|
||||
|
||||
// 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
|
||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
||||
func runNavidrome(parentCtx context.Context) error {
|
||||
defer db.Init(parentCtx)()
|
||||
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||
// it will cancel the context and exit gracefully.
|
||||
func runNavidrome(ctx context.Context) {
|
||||
defer db.Init(ctx)()
|
||||
|
||||
g, ctx := errgroup.WithContext(parentCtx)
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(startServer(ctx))
|
||||
g.Go(startSignaller(ctx))
|
||||
g.Go(startScheduler(ctx))
|
||||
g.Go(startPlaybackServer(ctx))
|
||||
g.Go(startJellyfinDiscovery(ctx))
|
||||
g.Go(schedulePeriodicBackup(ctx))
|
||||
g.Go(startInsightsCollector(ctx))
|
||||
g.Go(scheduleDBAnalyzer(ctx))
|
||||
|
|
@ -104,11 +100,9 @@ func runNavidrome(parentCtx context.Context) error {
|
|||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||
}
|
||||
|
||||
// Errors caused by a normal shutdown are not failures
|
||||
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
||||
return err
|
||||
if err := g.Wait(); err != nil {
|
||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||
|
|
@ -137,9 +131,6 @@ func startServer(ctx context.Context) func() error {
|
|||
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 {
|
||||
p := CreatePrometheus()
|
||||
// blocking call because takes <100ms but useful if fails
|
||||
|
|
@ -147,7 +138,7 @@ func startServer(ctx context.Context) func() error {
|
|||
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
||||
}
|
||||
if conf.Server.DevEnableProfiler {
|
||||
a.MountRouter("Profiling", "/debug", profilerHandler())
|
||||
a.MountRouter("Profiling", "/debug", middleware.Profiler())
|
||||
}
|
||||
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||
|
|
@ -156,14 +147,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.
|
||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
|
|
@ -190,50 +173,46 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
|||
}
|
||||
}
|
||||
|
||||
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
||||
// the one used by their last finished scan
|
||||
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
||||
libs, err := ds.Library().GetAll(ctx)
|
||||
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return false, err
|
||||
}
|
||||
var names []string
|
||||
for _, lib := range libs {
|
||||
if lib.PIDChanged() {
|
||||
names = append(names, lib.Name)
|
||||
}
|
||||
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
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.
|
||||
func runInitialScan(ctx context.Context) func() error {
|
||||
return func() error {
|
||||
ds := CreateDataStore()
|
||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
||||
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
||||
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
||||
pidHasChanged, err := pidHashChanged(ds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||
if scanNeeded {
|
||||
s := CreateScanner(ctx)
|
||||
switch {
|
||||
case fullScanRequired == "1":
|
||||
log.Warn(ctx, "Full scan required after migration")
|
||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
||||
case len(pidChangedLibs) > 0:
|
||||
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
||||
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
||||
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||
case pidHasChanged:
|
||||
log.Warn(ctx, "PID config changed, performing full scan")
|
||||
fullScanRequired = "1"
|
||||
case inProgress:
|
||||
log.Warn(ctx, "Resuming interrupted scan")
|
||||
default:
|
||||
|
|
@ -355,18 +334,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.
|
||||
// It is responsible for the Jukebox functionality
|
||||
func startPlaybackServer(ctx context.Context) func() error {
|
||||
|
|
@ -386,50 +353,50 @@ func startPlaybackServer(ctx context.Context) func() error {
|
|||
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.
|
||||
// scheduleArtworkHousekeeping runs the startup fingerprint backfill and registers the
|
||||
// recurring stale-absent recheck and prune jobs. Scan-triggered prune lands in a later phase.
|
||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||
return func() error {
|
||||
ds := CreateDataStore()
|
||||
schedulerInstance := scheduler.GetInstance()
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkStaleAbsentRecheckSchedule, func() {
|
||||
if err := artwork.EnqueueStaleAbsentAll(ctx, ds); err != nil {
|
||||
log.Error(ctx, "Error enqueueing stale artwork rechecks", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
||||
log.Error(ctx, "Error scheduling artwork stale-absent recheck", err)
|
||||
}
|
||||
|
||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
||||
outsideScan(ctx, "artwork prune", worker.RunPrune)
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running artwork prune", err)
|
||||
}
|
||||
}); 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)
|
||||
backfilled, err := artwork.Backfill(ctx, ds)
|
||||
if err != nil {
|
||||
log.Error(ctx, "Error running artwork backfill", err)
|
||||
return nil
|
||||
}
|
||||
if !backfilled {
|
||||
return nil
|
||||
}
|
||||
log.Info(ctx, "Artwork backfill enqueued, scheduling a follow-up prune")
|
||||
timer := time.NewTimer(consts.ArtworkPostBackfillPruneDelay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
if err := worker.RunPrune(ctx); err != nil {
|
||||
log.Error(ctx, "Error running post-backfill artwork prune", err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -485,7 +452,7 @@ func init() {
|
|||
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
|
||||
rootCmd.Flags().Bool("autoimportplaylists", viper.GetBool("autoimportplaylists"), "enable/disable .m3u playlist auto-import`")
|
||||
|
||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint")
|
||||
rootCmd.Flags().Bool("prometheus.enabled", viper.GetBool("prometheus.enabled"), "enable/disable prometheus metrics endpoint`")
|
||||
rootCmd.Flags().String("prometheus.metricspath", viper.GetString("prometheus.metricspath"), "http endpoint for prometheus metrics")
|
||||
|
||||
_ = viper.BindPFlag("address", rootCmd.Flags().Lookup("address"))
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
})
|
||||
})
|
||||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"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/log"
|
||||
|
|
@ -82,7 +82,7 @@ func runScanner(ctx context.Context) {
|
|||
sqlDB := db.Db()
|
||||
defer db.Db().Close()
|
||||
ds := persistence.New(sqlDB)
|
||||
pls := playlists.NewPlaylists(ds, artwork.NewUploader(ds))
|
||||
pls := playlists.NewPlaylists(ds, core.NewImageUploadService(ds))
|
||||
|
||||
// Parse targets from command line or file
|
||||
var scanTargets []model.ScanTarget
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
11
cmd/svc.go
11
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
type svcControl struct {
|
||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
|
@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
|
|||
p.done = make(chan struct{})
|
||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||
go func() {
|
||||
err := runNavidrome(p.ctx)
|
||||
runNavidrome(p.ctx)
|
||||
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
|
||||
}
|
||||
|
|
@ -79,7 +74,7 @@ func (p *svcControl) Stop(service.Service) error {
|
|||
var svcInstance = sync.OnceValue(func() service.Service {
|
||||
options := make(service.KeyValue)
|
||||
options["Restart"] = "on-failure"
|
||||
options["SuccessExitStatus"] = "SIGKILL"
|
||||
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||
options["UserService"] = false
|
||||
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||
options["SystemdScript"] = systemdScript
|
||||
|
|
|
|||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
|||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
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 {
|
||||
return fmt.Errorf("existing user '%s'", userID)
|
||||
}
|
||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
|||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
|||
return libraryError(user.Libraries)
|
||||
}
|
||||
} else {
|
||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
||||
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.User().Put(ctx, &user)
|
||||
err = tx.User(ctx).Put(&user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
return err
|
||||
})
|
||||
|
||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
var user *model.User
|
||||
|
||||
err = ds.WithTx(func(tx model.DataStore) error {
|
||||
count, err := tx.User().CountAll(ctx)
|
||||
count, err := tx.User(ctx).CountAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
|||
return err
|
||||
}
|
||||
|
||||
return tx.User().Delete(ctx, user.ID)
|
||||
return tx.User(ctx).Delete(user.ID)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
|||
}
|
||||
|
||||
if setAdmin && !user.IsAdmin {
|
||||
libraries, err := tx.Library().GetAll(ctx)
|
||||
libraries, err := tx.Library(ctx).GetAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
|||
return nil
|
||||
}
|
||||
|
||||
err := tx.User().Put(ctx, user)
|
||||
err := tx.User(ctx).Put(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
|||
updatedIds[idx] = lib.ID
|
||||
}
|
||||
|
||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -393,11 +393,13 @@ func runUserList(ctx context.Context) {
|
|||
|
||||
ds, ctx := getAdminContext(ctx)
|
||||
|
||||
userList, err := ds.User().ReadAll(ctx)
|
||||
users, err := ds.User(ctx).ReadAll()
|
||||
if err != nil {
|
||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||
}
|
||||
|
||||
userList := users.(model.Users)
|
||||
|
||||
if outputFormat == "csv" {
|
||||
w := csv.NewWriter(os.Stdout)
|
||||
_ = w.Write([]string{
|
||||
|
|
|
|||
36
cmd/utils.go
36
cmd/utils.go
|
|
@ -4,12 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
"github.com/navidrome/navidrome/core/auth"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
"github.com/navidrome/navidrome/log"
|
||||
|
|
@ -18,33 +13,6 @@ import (
|
|||
"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) {
|
||||
sqlDB := db.Db()
|
||||
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) {
|
||||
user, err := ds.User().FindByUsername(ctx, id)
|
||||
user, err := ds.User(ctx).FindByUsername(id)
|
||||
|
||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
user, err = ds.User().Get(ctx, id)
|
||||
user, err = ds.User(ctx).Get(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import (
|
|||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"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/sonic"
|
||||
"github.com/navidrome/navidrome/core/stream"
|
||||
|
|
@ -31,7 +30,6 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -67,22 +65,18 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
share := core.NewShare(dataStore)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
imageUploadService := core.NewImageUploadService(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
insights := metrics.GetInstance(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||
user := core.NewUser(dataStore, manager)
|
||||
maintenance := core.NewMaintenance(dataStore)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
quickConnect := quickconnect.GetInstance()
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, imageUploadService)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -90,29 +84,29 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
imageStore := artwork.ProvideImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
service := artwork.NewService(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)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
players := core.NewPlayers(dataStore)
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
imageUploadService := core.NewImageUploadService(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||
playbackServer := playback.GetInstance(dataStore)
|
||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
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)
|
||||
router := subsonic.New(dataStore, service, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -120,9 +114,9 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
imageStore := artwork.ProvideImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
service := artwork.NewService(dataStore, fileCache, imageStore, fFmpeg)
|
||||
transcodingCache := stream.GetTranscodingCache()
|
||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||
|
|
@ -131,22 +125,14 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
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)
|
||||
imageUploadService := core.NewImageUploadService(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||
matcherMatcher := matcher.New(dataStore)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher)
|
||||
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)
|
||||
router := jellyfin.New(dataStore, service, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -154,15 +140,14 @@ func CreatePublicRouter() *public.Router {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
fileCache := artwork.GetImageCache()
|
||||
imageStore := artwork.GetImageStore()
|
||||
imageStore := artwork.ProvideImageStore()
|
||||
fFmpeg := ffmpeg.New()
|
||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||
service := artwork.NewService(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)
|
||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
||||
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||
router := public.New(dataStore, service, mediaStreamer, share, archiver)
|
||||
return router
|
||||
}
|
||||
|
||||
|
|
@ -180,13 +165,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
return router
|
||||
}
|
||||
|
||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
discovery := jellyfin.NewDiscovery(dataStore)
|
||||
return discovery
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
|
|
@ -205,10 +183,10 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
imageUploadService := core.NewImageUploadService(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
return modelScanner
|
||||
}
|
||||
|
||||
|
|
@ -216,10 +194,10 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
|||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
broker := events.GetBroker()
|
||||
uploader := artwork.NewUploader(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||
imageUploadService := core.NewImageUploadService(dataStore)
|
||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, imageUploadService)
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||
return watcher
|
||||
}
|
||||
|
|
@ -234,7 +212,7 @@ func GetPlaybackServer() playback.PlaybackServer {
|
|||
func CreateArtworkWorker() *artwork.Worker {
|
||||
sqlDB := db.Db()
|
||||
dataStore := persistence.New(sqlDB)
|
||||
imageStore := artwork.GetImageStore()
|
||||
imageStore := artwork.ProvideImageStore()
|
||||
broker := events.GetBroker()
|
||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||
|
|
@ -245,18 +223,6 @@ func CreateArtworkWorker() *artwork.Worker {
|
|||
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)
|
||||
|
|
@ -268,7 +234,7 @@ func getPluginManager() *plugins.Manager {
|
|||
|
||||
// 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, jellyfin.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, 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)))
|
||||
|
||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||
manager := getPluginManager()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"github.com/navidrome/navidrome/core/lyrics"
|
||||
"github.com/navidrome/navidrome/core/metrics"
|
||||
"github.com/navidrome/navidrome/core/playback"
|
||||
"github.com/navidrome/navidrome/core/playlists"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
"github.com/navidrome/navidrome/core/sonic"
|
||||
"github.com/navidrome/navidrome/db"
|
||||
|
|
@ -23,7 +22,6 @@ import (
|
|||
"github.com/navidrome/navidrome/plugins"
|
||||
"github.com/navidrome/navidrome/scanner"
|
||||
"github.com/navidrome/navidrome/server"
|
||||
"github.com/navidrome/navidrome/server/apiv1"
|
||||
"github.com/navidrome/navidrome/server/events"
|
||||
"github.com/navidrome/navidrome/server/jellyfin"
|
||||
"github.com/navidrome/navidrome/server/nativeapi"
|
||||
|
|
@ -37,15 +35,13 @@ var allProviders = wire.NewSet(
|
|||
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.New,
|
||||
scanner.GetWatcher,
|
||||
metrics.GetPrometheusInstance,
|
||||
db.Db,
|
||||
|
|
@ -60,7 +56,6 @@ var allProviders = wire.NewSet(
|
|||
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)),
|
||||
)
|
||||
|
||||
func CreateDataStore() model.DataStore {
|
||||
|
|
@ -93,12 +88,6 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreatePublicRouter() *public.Router {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
@ -117,12 +106,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
))
|
||||
}
|
||||
|
||||
func CreateInsights() metrics.Insights {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
@ -159,13 +142,6 @@ func CreateArtworkWorker() *artwork.Worker {
|
|||
))
|
||||
}
|
||||
|
||||
func CreateArtworkResolver(trace *artwork.ChainTrace, live bool) *artwork.TracingResolver {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
artwork.NewTracingResolver,
|
||||
))
|
||||
}
|
||||
|
||||
func getPluginManager() *plugins.Manager {
|
||||
panic(wire.Build(
|
||||
allProviders,
|
||||
|
|
|
|||
|
|
@ -2,19 +2,14 @@ package conf
|
|||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
|
|
@ -26,12 +21,11 @@ import (
|
|||
"github.com/navidrome/navidrome/log"
|
||||
"github.com/navidrome/navidrome/scheduler"
|
||||
"github.com/navidrome/navidrome/utils/run"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
type configOptions struct {
|
||||
ConfigFile string `conf:"-"`
|
||||
ConfigFile string
|
||||
Address string
|
||||
Port int
|
||||
UnixSocketPerm string
|
||||
|
|
@ -63,6 +57,8 @@ type configOptions struct {
|
|||
ImageCacheSize string
|
||||
AlbumPlayCountMode string
|
||||
EnableArtworkPrecache bool
|
||||
ArtworkWorkerConcurrency int
|
||||
ArtworkExternalMaxRPS int
|
||||
AutoImportPlaylists bool
|
||||
DefaultPlaylistPublicVisibility bool
|
||||
PlaylistsPath string
|
||||
|
|
@ -73,7 +69,6 @@ type configOptions struct {
|
|||
Matcher matcherOptions `json:",omitzero"`
|
||||
RecentlyAddedByModTime bool
|
||||
PreferSortTags bool
|
||||
EnableNaturalSorting bool
|
||||
IgnoredArticles string
|
||||
IndexGroups string
|
||||
FFmpegPath string
|
||||
|
|
@ -92,7 +87,6 @@ type configOptions struct {
|
|||
EnableUserEditing bool
|
||||
EnableArtworkUpload bool
|
||||
MaxImageUploadSize string
|
||||
MaxImageSize string
|
||||
EnableSharing bool
|
||||
ShareURL string
|
||||
DefaultShareExpiration time.Duration
|
||||
|
|
@ -147,8 +141,6 @@ type configOptions struct {
|
|||
DevArtworkThrottleBacklogLimit int
|
||||
DevArtworkThrottleBacklogTimeout time.Duration
|
||||
DevArtworkThrottleBuffered bool
|
||||
DevArtworkWorkerConcurrency int
|
||||
DevArtworkExternalMaxRPS int
|
||||
DevArtistInfoTimeToLive time.Duration
|
||||
DevAlbumInfoTimeToLive time.Duration
|
||||
DevExternalScanner bool
|
||||
|
|
@ -161,8 +153,6 @@ type configOptions struct {
|
|||
DevExternalArtistFetchMultiplier float64
|
||||
DevPreserveUnicodeInExternalCalls bool
|
||||
DevEnableMediaFileProbe bool
|
||||
DevMatchEstimatedContentLength bool
|
||||
DevAPIv1 bool
|
||||
}
|
||||
|
||||
type scannerOptions struct {
|
||||
|
|
@ -213,7 +203,7 @@ type lastfmOptions struct {
|
|||
ScrobbleFirstArtistOnly bool
|
||||
|
||||
// Computed values
|
||||
Languages []string `conf:"-"` // Computed from Language, split by comma
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type deezerOptions struct {
|
||||
|
|
@ -221,7 +211,7 @@ type deezerOptions struct {
|
|||
Language string
|
||||
|
||||
// Computed values
|
||||
Languages []string `conf:"-"` // Computed from Language, split by comma
|
||||
Languages []string // Computed from Language, split by comma
|
||||
}
|
||||
|
||||
type listenBrainzOptions struct {
|
||||
|
|
@ -237,8 +227,6 @@ type jellyfinOptions struct {
|
|||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
||||
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
||||
ExposedPublicUsers string
|
||||
AutoDiscovery bool
|
||||
QuickConnect bool
|
||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
||||
// cursor — and its pooled connection — for the whole client-paced response, so without a bound
|
||||
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
||||
|
|
@ -319,12 +307,6 @@ var currentGOOS = func() string {
|
|||
return runtime.GOOS
|
||||
}
|
||||
|
||||
// TLSEnabled reports whether the server serves HTTPS. Both halves are required,
|
||||
// so callers cannot infer it from the certificate alone.
|
||||
func (c *configOptions) TLSEnabled() bool {
|
||||
return c.TLSCert != "" && c.TLSKey != ""
|
||||
}
|
||||
|
||||
var (
|
||||
Server = &configOptions{}
|
||||
hooks []func()
|
||||
|
|
@ -355,23 +337,19 @@ func LoadFromFile(confFile string) {
|
|||
Load(true)
|
||||
}
|
||||
|
||||
func durationNonNegativeOrDefault(val *time.Duration, original time.Duration) {
|
||||
if val.Nanoseconds() < 0 {
|
||||
log.Warn("Duration is a negative value. Using default value", "value", *val, "default", original)
|
||||
*val = original
|
||||
}
|
||||
}
|
||||
|
||||
func Load(noConfigDump bool) {
|
||||
parseIniFileConfiguration()
|
||||
remapEnvVarKeysFromConfig()
|
||||
|
||||
// Map deprecated options to their new names for backwards compatibility
|
||||
for _, o := range deprecatedOptions {
|
||||
if o.replacement != "" {
|
||||
mapDeprecatedOption(o.name, o.replacement)
|
||||
}
|
||||
}
|
||||
mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality")
|
||||
mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
mapDeprecatedOption("DevArtworkWorkerConcurrency", "ArtworkWorkerConcurrency")
|
||||
mapDeprecatedOption("DevArtworkExternalRPS", "ArtworkExternalMaxRPS")
|
||||
|
||||
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
||||
mapstructure.ComposeDecodeHookFunc(
|
||||
|
|
@ -411,7 +389,7 @@ func Load(noConfigDump bool) {
|
|||
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
||||
}
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
|
||||
out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||
}
|
||||
|
|
@ -429,34 +407,12 @@ func Load(noConfigDump bool) {
|
|||
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||
log.SetRedacting(Server.EnableLogRedacting)
|
||||
|
||||
durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout)
|
||||
durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh)
|
||||
durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration)
|
||||
durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval)
|
||||
durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength)
|
||||
durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait)
|
||||
|
||||
durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate)
|
||||
durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout)
|
||||
durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive)
|
||||
durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive)
|
||||
durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay)
|
||||
durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout)
|
||||
|
||||
// Log deprecated, removed and unknown options
|
||||
for _, o := range deprecatedOptions {
|
||||
logDeprecatedOptions(o.name, o.replacement)
|
||||
}
|
||||
logRemovedOptions(removedOptions...)
|
||||
logUnknownOptions()
|
||||
|
||||
err = run.Sequentially(
|
||||
validateScanSchedule,
|
||||
validateBackupSchedule,
|
||||
validatePlaylistsPath,
|
||||
validatePurgeMissingOption,
|
||||
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
|
||||
validateByteSize("MaxImageSize", Server.MaxImageSize),
|
||||
validateMaxImageUploadSize,
|
||||
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -509,6 +465,21 @@ func Load(noConfigDump bool) {
|
|||
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
||||
|
||||
// Deprecated options
|
||||
logDeprecatedOptions("Scanner.GenreSeparators", "")
|
||||
logDeprecatedOptions("Scanner.GroupAlbumReleases", "")
|
||||
logDeprecatedOptions("DevEnableBufferedScrobble", "") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
|
||||
logDeprecatedOptions("SearchFullString", "Search.FullString")
|
||||
logDeprecatedOptions("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
|
||||
logDeprecatedOptions("ReverseProxyUserHeader", "ExtAuth.UserHeader")
|
||||
logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions")
|
||||
logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality")
|
||||
logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold")
|
||||
logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation")
|
||||
|
||||
// Removed options
|
||||
logRemovedOptions("Spotify.ID", "Spotify.Secret")
|
||||
|
||||
// Validate other options
|
||||
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
|
||||
newValue := max(200, min(1200, Server.UICoverArtSize))
|
||||
|
|
@ -516,45 +487,15 @@ func Load(noConfigDump bool) {
|
|||
Server.UICoverArtSize = newValue
|
||||
}
|
||||
|
||||
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
||||
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
||||
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||
}
|
||||
|
||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||
if imgSize < uploadSize {
|
||||
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
|
||||
Server.MaxImageSize = Server.MaxImageUploadSize
|
||||
}
|
||||
|
||||
// Call init hooks
|
||||
for _, hook := range hooks {
|
||||
hook()
|
||||
}
|
||||
}
|
||||
|
||||
// deprecatedOptions still work, but will be removed in a future release. An empty
|
||||
// replacement means the option is now ignored.
|
||||
var deprecatedOptions = []struct{ name, replacement string }{
|
||||
{"Scanner.GenreSeparators", ""},
|
||||
{"Scanner.GroupAlbumReleases", ""},
|
||||
{"DevEnableBufferedScrobble", ""},
|
||||
{"SearchFullString", "Search.FullString"},
|
||||
{"ReverseProxyWhitelist", "ExtAuth.TrustedSources"},
|
||||
{"ReverseProxyUserHeader", "ExtAuth.UserHeader"},
|
||||
{"HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions"},
|
||||
{"CoverJpegQuality", "CoverArtQuality"},
|
||||
{"SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold"},
|
||||
{"EnableTranscodingCancellation", "Transcoding.EnableCancellation"},
|
||||
}
|
||||
|
||||
var removedOptions = []string{"Spotify.ID", "Spotify.Secret"}
|
||||
|
||||
func logDeprecatedOptions(oldName, newName string) {
|
||||
envVar := envVarName(oldName)
|
||||
newEnvVar := envVarName(newName)
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(oldName, ".", "_"))
|
||||
newEnvVar := "ND_" + strings.ToUpper(strings.ReplaceAll(newName, ".", "_"))
|
||||
logWarning := func(oldName, newName string) {
|
||||
if newName != "" {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is deprecated and will be ignored in a future release. Please use the new '%s'", oldName, newName))
|
||||
|
|
@ -574,7 +515,7 @@ func logDeprecatedOptions(oldName, newName string) {
|
|||
// not available anymore
|
||||
func logRemovedOptions(options ...string) {
|
||||
for _, option := range options {
|
||||
envVar := envVarName(option)
|
||||
envVar := "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
logWarning := func(option string) {
|
||||
log.Warn(fmt.Sprintf("Option '%s' is not available anymore and will be ignored. Please remove it from your config", option))
|
||||
}
|
||||
|
|
@ -595,193 +536,35 @@ func remapEnvVarKeysFromConfig() {
|
|||
continue
|
||||
}
|
||||
stripped := strings.TrimPrefix(key, "nd_")
|
||||
canonicalKey := ndKeyToCanonical(key)
|
||||
canonicalKey := strings.ReplaceAll(stripped, "_", ".")
|
||||
displayNDKey := "ND_" + strings.ToUpper(stripped)
|
||||
canonicalName := canonicalOptionName(canonicalKey)
|
||||
displayCanonical := toPascalCase(canonicalKey)
|
||||
|
||||
if viper.InConfig(canonicalKey) {
|
||||
logFatal(fmt.Sprintf(
|
||||
"Config file contains both '%s' and '%s'. Remove the ND_-prefixed version. "+
|
||||
"The 'ND_' prefix is only needed for environment variables, not config file keys.",
|
||||
displayNDKey, cmp.Or(canonicalName, toPascalCase(canonicalKey)),
|
||||
displayNDKey, displayCanonical,
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
viper.Set(canonicalKey, viper.Get(key))
|
||||
// Unknown keys get no advice here, logUnknownOptions reports them instead
|
||||
if canonicalName != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
|
||||
"The 'ND_' prefix is only needed for environment variables.\n",
|
||||
displayNDKey, canonicalName,
|
||||
)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stderr, "WARNING: Config key '%s' uses environment variable naming. Use '%s' instead. "+
|
||||
"The 'ND_' prefix is only needed for environment variables.\n",
|
||||
displayNDKey, displayCanonical,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
|
||||
// the config has been read by viper, but before unmarshalling it into the Config struct.
|
||||
func mapDeprecatedOption(legacyName, newName string) {
|
||||
// viper.Set outranks the config file, so an explicit replacement must win over the legacy value
|
||||
if viper.IsSet(legacyName) && !explicitlySet(newName) {
|
||||
if viper.IsSet(legacyName) {
|
||||
viper.Set(newName, viper.Get(legacyName))
|
||||
}
|
||||
}
|
||||
|
||||
// explicitlySet reports whether the user provided the option, ignoring defaults,
|
||||
// which viper.IsSet counts as set. The ND_ spelling is also accepted in the config
|
||||
// file, and remapEnvVarKeysFromConfig has already moved it out of InConfig's reach.
|
||||
func explicitlySet(name string) bool {
|
||||
envVar := envVarName(name)
|
||||
return viper.InConfig(name) || os.Getenv(envVar) != "" || viper.InConfig(strings.ToLower(envVar))
|
||||
}
|
||||
|
||||
func envVarName(option string) string {
|
||||
if option == "" {
|
||||
return ""
|
||||
}
|
||||
return "ND_" + strings.ToUpper(strings.ReplaceAll(option, ".", "_"))
|
||||
}
|
||||
|
||||
func logUnknownOptions() {
|
||||
for _, key := range unknownConfigKeys() {
|
||||
msg := fmt.Sprintf("Option '%s' is not recognized and will be ignored", key)
|
||||
if matches := suggestOptions(key); len(matches) > 0 {
|
||||
msg += fmt.Sprintf(". Did you mean '%s'?", strings.Join(matches, "' or '"))
|
||||
}
|
||||
log.Warn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// suggestOptions returns the known options sharing the last segment with key,
|
||||
// catching options written outside their section.
|
||||
func suggestOptions(key string) []string {
|
||||
key = strings.ToLower(key)
|
||||
leaf := leafKey(key)
|
||||
canonical, _ := configKeys()
|
||||
var matches []string
|
||||
for known, name := range canonical {
|
||||
// Removed options are known only so they get their own warning, never suggest them
|
||||
if known != key && leafKey(known) == leaf && !slices.Contains(removedOptions, name) {
|
||||
matches = append(matches, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches
|
||||
}
|
||||
|
||||
func leafKey(key string) string {
|
||||
return key[strings.LastIndex(key, ".")+1:]
|
||||
}
|
||||
|
||||
// unknownConfigKeys returns config file keys that don't match any known option, so
|
||||
// typos and options written outside their section don't fail silently.
|
||||
func unknownConfigKeys() []string {
|
||||
// INI files keep the original [default] section alongside the merged one
|
||||
skipDefault := strings.EqualFold(filepath.Ext(viper.ConfigFileUsed()), ".ini")
|
||||
|
||||
var unknown []string
|
||||
for _, key := range viper.AllKeys() {
|
||||
if !viper.InConfig(key) || canonicalOptionName(key) != "" {
|
||||
continue
|
||||
}
|
||||
if skipDefault && strings.HasPrefix(key, "default.") {
|
||||
continue
|
||||
}
|
||||
// Only ND_-prefixed keys that remapEnvVarKeysFromConfig could resolve are valid
|
||||
if strings.HasPrefix(key, "nd_") && canonicalOptionName(ndKeyToCanonical(key)) != "" {
|
||||
continue
|
||||
}
|
||||
unknown = append(unknown, key)
|
||||
}
|
||||
slices.Sort(unknown)
|
||||
return asWrittenInConfigFile(unknown)
|
||||
}
|
||||
|
||||
func ndKeyToCanonical(key string) string {
|
||||
return strings.ReplaceAll(strings.TrimPrefix(key, "nd_"), "_", ".")
|
||||
}
|
||||
|
||||
// canonicalOptionName returns the documented spelling of a known option key, or ""
|
||||
// if it matches no option. Subkeys of free-form maps have no fixed spelling.
|
||||
func canonicalOptionName(key string) string {
|
||||
keys, prefixes := configKeys()
|
||||
if name, ok := keys[key]; ok {
|
||||
return name
|
||||
}
|
||||
if slices.ContainsFunc(prefixes, func(p string) bool { return strings.HasPrefix(key, p) }) {
|
||||
return toPascalCase(key)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// asWrittenInConfigFile restores the casing the keys have in the config file, as
|
||||
// viper lowercases every key it loads.
|
||||
func asWrittenInConfigFile(keys []string) []string {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(viper.ConfigFileUsed())
|
||||
if err != nil {
|
||||
return keys
|
||||
}
|
||||
casing := map[string]string{}
|
||||
for _, match := range configFileKeyRx.FindAllStringSubmatch(string(data), -1) {
|
||||
for segment := range strings.SplitSeq(match[1], ".") {
|
||||
lower := strings.ToLower(segment)
|
||||
casing[lower] = cmp.Or(casing[lower], segment)
|
||||
}
|
||||
}
|
||||
return slice.Map(keys, func(key string) string {
|
||||
segments := strings.Split(key, ".")
|
||||
for i, s := range segments {
|
||||
segments[i] = cmp.Or(casing[s], s)
|
||||
}
|
||||
return strings.Join(segments, ".")
|
||||
})
|
||||
}
|
||||
|
||||
// Matches keys and section headers in all supported config formats.
|
||||
var configFileKeyRx = regexp.MustCompile(`(?m)^\s*\[?\s*"?([\w.]+)"?\s*[]=:]`)
|
||||
|
||||
// configKeys maps every accepted option name, lowercased, to its canonical spelling,
|
||||
// plus the prefixes of free-form map options (Tags, DevLogLevels).
|
||||
var configKeys = sync.OnceValues(func() (map[string]string, []string) {
|
||||
keys := map[string]string{}
|
||||
var prefixes []string
|
||||
|
||||
var collect func(t reflect.Type, prefix string)
|
||||
collect = func(t reflect.Type, prefix string) {
|
||||
for field := range t.Fields() {
|
||||
// `conf:"-"` marks values computed during Load, not settable in the config
|
||||
if !field.IsExported() || field.Tag.Get("conf") == "-" {
|
||||
continue
|
||||
}
|
||||
name := prefix + field.Name
|
||||
if field.Type.Kind() == reflect.Struct && !reflect.PointerTo(field.Type).Implements(textUnmarshalerType) {
|
||||
collect(field.Type, name+".")
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
keys[lower] = name
|
||||
if field.Type.Kind() == reflect.Map {
|
||||
prefixes = append(prefixes, lower+".")
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(reflect.TypeFor[configOptions](), "")
|
||||
|
||||
for _, o := range deprecatedOptions {
|
||||
keys[strings.ToLower(o.name)] = o.name
|
||||
}
|
||||
for _, o := range removedOptions {
|
||||
keys[strings.ToLower(o)] = o
|
||||
}
|
||||
return keys, prefixes
|
||||
})
|
||||
|
||||
var textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
|
||||
|
||||
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
|
||||
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
|
||||
// section into the root level.
|
||||
|
|
@ -854,20 +637,11 @@ func validatePurgeMissingOption() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func validateByteSize(name, value string) func() error {
|
||||
return func() error {
|
||||
size, err := humanize.ParseBytes(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid %s %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", name, value, err)
|
||||
}
|
||||
if size == 0 {
|
||||
return fmt.Errorf("invalid %s %q: must be greater than zero", name, value)
|
||||
}
|
||||
if size > math.MaxInt64 {
|
||||
return fmt.Errorf("invalid %s %q: value is too large", name, value)
|
||||
}
|
||||
return nil
|
||||
func validateMaxImageUploadSize() error {
|
||||
if _, err := humanize.ParseBytes(Server.MaxImageUploadSize); err != nil {
|
||||
return fmt.Errorf("invalid MaxImageUploadSize %q: use values like '10MB', '1GB', or raw bytes like '10485760': %w", Server.MaxImageUploadSize, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEnforceNonRootUser() error {
|
||||
|
|
@ -997,7 +771,7 @@ func setViperDefaults() {
|
|||
viper.SetDefault("autoimportplaylists", true)
|
||||
viper.SetDefault("defaultplaylistpublicvisibility", false)
|
||||
viper.SetDefault("playlistspath", "")
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", consts.DefaultSmartRefresh)
|
||||
viper.SetDefault("smartPlaylistRefreshDelay", 5*time.Second)
|
||||
viper.SetDefault("enabledownloads", true)
|
||||
viper.SetDefault("enableexternalservices", true)
|
||||
viper.SetDefault("enablem3uexternalalbumart", false)
|
||||
|
|
@ -1010,7 +784,6 @@ func setViperDefaults() {
|
|||
viper.SetDefault("matcher.fuzzythreshold", 85)
|
||||
viper.SetDefault("recentlyaddedbymodtime", false)
|
||||
viper.SetDefault("prefersorttags", false)
|
||||
viper.SetDefault("enablenaturalsorting", false)
|
||||
viper.SetDefault("ignoredarticles", "The El La Los Las Le Les Os As O A")
|
||||
viper.SetDefault("indexgroups", "A B C D E F G H I J K L M N O P Q R S T U V W X-Z(XYZ) [Unknown]([)")
|
||||
viper.SetDefault("ffmpegpath", "")
|
||||
|
|
@ -1038,17 +811,16 @@ func setViperDefaults() {
|
|||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||
viper.SetDefault("enableartworkupload", true)
|
||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
||||
viper.SetDefault("enablesharing", true)
|
||||
viper.SetDefault("shareurl", "")
|
||||
viper.SetDefault("defaultshareexpiration", consts.DefaultShareExpiration)
|
||||
viper.SetDefault("defaultshareexpiration", 8760*time.Hour)
|
||||
viper.SetDefault("defaultdownloadableshare", false)
|
||||
viper.SetDefault("gatrackingid", "")
|
||||
viper.SetDefault("enableinsightscollector", true)
|
||||
viper.SetDefault("enablescheduleddbanalyze", true)
|
||||
viper.SetDefault("enablelogredacting", true)
|
||||
viper.SetDefault("authrequestlimit", 5)
|
||||
viper.SetDefault("authwindowlength", consts.DefaultAuthWindowLength)
|
||||
viper.SetDefault("authwindowlength", 20*time.Second)
|
||||
viper.SetDefault("passwordencryptionkey", "")
|
||||
viper.SetDefault("extauth.userheader", "Remote-User")
|
||||
viper.SetDefault("extauth.trustedsources", "")
|
||||
|
|
@ -1096,8 +868,6 @@ func setViperDefaults() {
|
|||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||
viper.SetDefault("jellyfin.enabled", false)
|
||||
viper.SetDefault("jellyfin.servername", "")
|
||||
viper.SetDefault("jellyfin.autodiscovery", false)
|
||||
viper.SetDefault("jellyfin.quickconnect", true)
|
||||
viper.SetDefault("enablescrobblehistory", true)
|
||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||
viper.SetDefault("backup.path", "")
|
||||
|
|
@ -1126,7 +896,6 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devshowartistpage", true)
|
||||
viper.SetDefault("devuishowconfig", true)
|
||||
viper.SetDefault("devneweventstream", true)
|
||||
viper.SetDefault("devapiv1", false)
|
||||
viper.SetDefault("devoffsetoptimize", 50000)
|
||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||
// scrobbles and the UI. See MaxOpenConns.
|
||||
|
|
@ -1135,12 +904,8 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit)
|
||||
viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout)
|
||||
viper.SetDefault("devartworkthrottlebuffered", true)
|
||||
// Half the CPU count (min 2), so local resolution scales with the host but stays under the
|
||||
// SQLite pool (MaxOpenConns) — leaving connections for the scanner, scrobbles and the UI.
|
||||
viper.SetDefault("devartworkworkerconcurrency", max(2, runtime.NumCPU()/2))
|
||||
// External RPS gates outbound calls to third-party services (per service); it is bounded by
|
||||
// their tolerance, not the host, so it stays a small constant regardless of CPU count.
|
||||
viper.SetDefault("devartworkexternalmaxrps", 2)
|
||||
viper.SetDefault("artworkworkerconcurrency", 4)
|
||||
viper.SetDefault("artworkexternalmaxrps", 2)
|
||||
viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive)
|
||||
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
|
||||
viper.SetDefault("devexternalscanner", true)
|
||||
|
|
@ -1153,7 +918,6 @@ func setViperDefaults() {
|
|||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||
viper.SetDefault("devenablemediafileprobe", true)
|
||||
viper.SetDefault("devmatchestimatedcontentlength", true)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
|
|
@ -1,18 +1,12 @@
|
|||
package conf_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/gomega"
|
||||
"github.com/spf13/viper"
|
||||
|
|
@ -184,123 +178,6 @@ var _ = Describe("Configuration", func() {
|
|||
})
|
||||
})
|
||||
|
||||
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() {
|
||||
|
|
@ -330,21 +207,6 @@ var _ = Describe("Configuration", func() {
|
|||
}).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")
|
||||
|
|
@ -355,10 +217,19 @@ var _ = Describe("Configuration", func() {
|
|||
|
||||
})
|
||||
|
||||
Describe("ValidateByteSize", func() {
|
||||
Describe("ValidateMaxImageUploadSize", func() {
|
||||
BeforeEach(func() {
|
||||
viper.Reset()
|
||||
conf.SetViperDefaults()
|
||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||
viper.SetDefault("loglevel", "error")
|
||||
conf.ResetConf()
|
||||
})
|
||||
|
||||
DescribeTable("accepts valid size values",
|
||||
func(input string) {
|
||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(Succeed())
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(Succeed())
|
||||
},
|
||||
Entry("megabytes", "10MB"),
|
||||
Entry("gigabytes", "1GB"),
|
||||
|
|
@ -369,60 +240,14 @@ var _ = Describe("Configuration", func() {
|
|||
|
||||
DescribeTable("rejects invalid size values",
|
||||
func(input string) {
|
||||
Expect(conf.ValidateByteSize("MaxImageSize", input)()).To(MatchError(ContainSubstring("invalid MaxImageSize")))
|
||||
conf.Server.MaxImageUploadSize = input
|
||||
Expect(conf.ValidateMaxImageUploadSize()).To(MatchError(ContainSubstring("invalid MaxImageUploadSize")))
|
||||
},
|
||||
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)
|
||||
|
|
@ -492,73 +317,4 @@ var _ = Describe("Configuration", func() {
|
|||
Entry("INI format", "ini"),
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ var NormalizeSearchBackend = normalizeSearchBackend
|
|||
|
||||
var ToPascalCase = toPascalCase
|
||||
|
||||
var ValidateByteSize = validateByteSize
|
||||
var ValidateMaxImageUploadSize = validateMaxImageUploadSize
|
||||
|
||||
func SetRuntimeInfoForTest(goos string, euid int) func() {
|
||||
oldGOOS := currentGOOS
|
||||
|
|
@ -32,7 +32,3 @@ func SetLogFatal(f func(...any)) func() {
|
|||
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"
|
||||
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"]
|
||||
3
conf/testdata/cfg_unknown_casing.ini
vendored
3
conf/testdata/cfg_unknown_casing.ini
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[default]
|
||||
MusicFolder = /ini/music
|
||||
NotAnOption = true
|
||||
4
conf/testdata/cfg_unknown_casing.json
vendored
4
conf/testdata/cfg_unknown_casing.json
vendored
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"MusicFolder": "/json/music",
|
||||
"NotAnOption": true
|
||||
}
|
||||
2
conf/testdata/cfg_unknown_casing.toml
vendored
2
conf/testdata/cfg_unknown_casing.toml
vendored
|
|
@ -1,2 +0,0 @@
|
|||
MusicFolder = "/toml/music"
|
||||
NotAnOption = true
|
||||
2
conf/testdata/cfg_unknown_casing.yaml
vendored
2
conf/testdata/cfg_unknown_casing.yaml
vendored
|
|
@ -1,2 +0,0 @@
|
|||
MusicFolder: /yaml/music
|
||||
NotAnOption: true
|
||||
18
conf/testdata/cfg_unknown_keys.toml
vendored
18
conf/testdata/cfg_unknown_keys.toml
vendored
|
|
@ -1,18 +0,0 @@
|
|||
MusicFolder = "/toml/music"
|
||||
|
||||
# Valid option, but written at the root level instead of under Scanner
|
||||
ArtistSplitExceptions = ["AC/DC", "Tyler, the creator"]
|
||||
|
||||
# Misspelled option
|
||||
EnableDownlods = true
|
||||
|
||||
# Unknown section
|
||||
[Whatever]
|
||||
Foo = "bar"
|
||||
|
||||
# Valid options, must not be reported
|
||||
[Scanner]
|
||||
ArtistJoiner = " • "
|
||||
|
||||
[Tags.custom]
|
||||
aliases = ["toml", "test"]
|
||||
7
conf/testdata/cfg_warning_output.toml
vendored
7
conf/testdata/cfg_warning_output.toml
vendored
|
|
@ -1,7 +0,0 @@
|
|||
MusicFolder = "/toml/music"
|
||||
LogLevel = "warn"
|
||||
ArtistSplitExceptions = ["AC/DC"]
|
||||
EnableDownlods = true
|
||||
|
||||
[Scanner]
|
||||
ArtistJoiner = " • "
|
||||
12
conf/testdata/invalid_duration.toml
vendored
12
conf/testdata/invalid_duration.toml
vendored
|
|
@ -1,12 +0,0 @@
|
|||
SessionTimeout = "-10s"
|
||||
SmartPlaylistRefreshDelay = "-10s"
|
||||
UIPlaybackReportInterval = "-10s"
|
||||
AuthWindowLength = "-10s"
|
||||
DefaultShareExpiration = "-10s"
|
||||
Scanner.WatcherWait = "-10s"
|
||||
DevActivityPanelUpdateRate = "-10s"
|
||||
DevArtworkThrottleBacklogTimeout = "-10s"
|
||||
DevArtistInfoTimeToLive = "-10s"
|
||||
DevAlbumInfoTimeToLive = "-10s"
|
||||
DevInsightsInitialDelay = "-10s"
|
||||
DevPluginCompilationTimeout = "-10s"
|
||||
12
conf/testdata/valid_duration.toml
vendored
12
conf/testdata/valid_duration.toml
vendored
|
|
@ -1,12 +0,0 @@
|
|||
SessionTimeout = "1s"
|
||||
SmartPlaylistRefreshDelay = "1s"
|
||||
UIPlaybackReportInterval = "1s"
|
||||
AuthWindowLength = "1s"
|
||||
DefaultShareExpiration = "1s"
|
||||
Scanner.WatcherWait = "1s"
|
||||
DevActivityPanelUpdateRate = "1s"
|
||||
DevArtworkThrottleBacklogTimeout = "1s"
|
||||
DevArtistInfoTimeToLive = "1s"
|
||||
DevAlbumInfoTimeToLive = "1s"
|
||||
DevInsightsInitialDelay = "1s"
|
||||
DevPluginCompilationTimeout = "1s"
|
||||
|
|
@ -24,32 +24,26 @@ const (
|
|||
LastDBAnalyzeAttemptAtKey = "LastDBAnalyzeAttemptAt"
|
||||
DBAnalyzePendingKey = "DBAnalyzePending"
|
||||
DBAnalyzeFailureCountKey = "DBAnalyzeFailureCount"
|
||||
// ArtConfFingerprintPropertyKey is the model.PropertyRepository key the artwork config check
|
||||
// compares against to detect artwork-affecting config changes across restarts.
|
||||
ArtConfFingerprintPropertyKey = "ArtConfFingerprint"
|
||||
|
||||
UIAuthorizationHeader = "X-ND-Authorization"
|
||||
UIClientUniqueIDHeader = "X-ND-Client-Unique-Id"
|
||||
JWTSecretKey = "JWTSecret"
|
||||
JWTPublicSecretKey = "JWTPublicSecret"
|
||||
JWTIssuer = "ND"
|
||||
DefaultSessionTimeout = 48 * time.Hour
|
||||
DefaultSmartRefresh = 5 * time.Second
|
||||
DefaultShareExpiration = 8760 * time.Hour
|
||||
CookieExpiry = 365 * 24 * 3600 // One year
|
||||
|
||||
DBAnalyzeCheckSchedule = "@every 30m"
|
||||
DBAnalyzeMaxAge = 24 * time.Hour
|
||||
|
||||
ArtworkEnqueueMissingSchedule = "@every 1h"
|
||||
ArtworkPruneSchedule = "@daily"
|
||||
ArtworkStaleAbsentRecheckSchedule = "@every 1h"
|
||||
ArtworkPruneSchedule = "@daily"
|
||||
ArtworkPostBackfillPruneDelay = 10 * time.Minute
|
||||
|
||||
// DefaultEncryptionKey This is the encryption key used if none is specified in the `PasswordEncryptionKey` option
|
||||
// Never ever change this! Or it will break all Navidrome installations that don't set the config option
|
||||
DefaultEncryptionKey = "just for obfuscation"
|
||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||
APIKeyPrefix = "nds_"
|
||||
|
||||
DevInitialUserName = "admin"
|
||||
DevInitialName = "Dev Admin"
|
||||
|
|
@ -60,7 +54,6 @@ const (
|
|||
URLPathPublic = "/share"
|
||||
URLPathPublicImages = URLPathPublic + "/img"
|
||||
URLPathJellyfinAPI = "/jellyfin"
|
||||
URLPathAPIv1 = "/api/v1"
|
||||
|
||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||
|
|
@ -75,7 +68,6 @@ const (
|
|||
DefaultUILoginBackgroundURLOffline = "data:image/png;base64," + DefaultUILoginBackgroundOffline
|
||||
DefaultMaxSidebarPlaylists = 100
|
||||
|
||||
DefaultAuthWindowLength = 20 * time.Second
|
||||
RequestThrottleBacklogLimit = 100
|
||||
RequestThrottleBacklogTimeout = time.Minute
|
||||
|
||||
|
|
@ -91,9 +83,6 @@ const (
|
|||
I18nFolder = "i18n"
|
||||
ScanIgnoreFile = ".ndignore"
|
||||
ArtworkFolder = "artwork"
|
||||
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
|
||||
// upload folders beside it so Prune's sweep never reaches them.
|
||||
HashedArtworkFolder = "hashed"
|
||||
|
||||
PlaceholderArtistArt = "artist-placeholder.webp"
|
||||
PlaceholderAlbumArt = "album-placeholder.webp"
|
||||
|
|
@ -111,15 +100,11 @@ const (
|
|||
DefaultScannerExtractor = "taglib"
|
||||
DefaultWatcherWait = 5 * time.Second
|
||||
Zwsp = string('\u200b')
|
||||
|
||||
DefaultActivityPanelUpdateRate = 300 * time.Millisecond
|
||||
DefaultPluginCompilationTimeout = time.Minute
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultUICoverArtSize = 300
|
||||
DefaultMaxImageUploadSize = "10MB"
|
||||
DefaultMaxImageSize = "20MB"
|
||||
)
|
||||
|
||||
// Prometheus options
|
||||
|
|
@ -156,6 +141,8 @@ const (
|
|||
//DefaultAlbumPID = "album_legacy"
|
||||
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
||||
PIDAlbumKey = "PIDAlbum"
|
||||
PIDTrackKey = "PIDTrack"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -206,7 +193,7 @@ var (
|
|||
}
|
||||
)
|
||||
|
||||
var HTTPUserAgent = "Navidrome/" + Version + " - https://github.com/navidrome"
|
||||
var HTTPUserAgent = "Navidrome" + "/" + Version
|
||||
|
||||
var (
|
||||
VariousArtists = "Various Artists"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
name=$RC_SVCNAME
|
||||
command="/opt/navidrome/${RC_SVCNAME}"
|
||||
command_args="--datafolder /opt/navidrome"
|
||||
command_args="-datafolder /opt/navidrome"
|
||||
command_user="${RC_SVCNAME}"
|
||||
pidfile="/var/run/${RC_SVCNAME}.pid"
|
||||
output_log="/opt/navidrome/${RC_SVCNAME}.log"
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ RestrictNamespaces=yes
|
|||
RestrictRealtime=yes
|
||||
SystemCallFilter=@system-service
|
||||
SystemCallFilter=~@privileged @resources
|
||||
SystemCallFilter=setrlimit mbind
|
||||
SystemCallFilter=setrlimit
|
||||
SystemCallArchitectures=native
|
||||
UMask=0066
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
package agents
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf"
|
||||
|
|
@ -26,43 +22,11 @@ type PluginLoader interface {
|
|||
LoadMediaAgent(name string) (Interface, bool)
|
||||
}
|
||||
|
||||
// agentCooldown is the default cooldown duration for an agent that returns a RetryLaterError without a specific
|
||||
// RetryIn duration.
|
||||
const agentCooldown = time.Minute
|
||||
|
||||
// errUnsupported marks an agent that does not implement the requested method: it never ran,
|
||||
// so it neither answered nor throttled.
|
||||
var errUnsupported = errors.New("agent does not support this method")
|
||||
|
||||
// Agents is a meta-agent that aggregates multiple built-in and plugin agents. It tries each enabled agent in order
|
||||
// until one returns valid data.
|
||||
type Agents struct {
|
||||
ds model.DataStore
|
||||
pluginLoader PluginLoader
|
||||
cooldowns cooldowns
|
||||
}
|
||||
|
||||
// cooldowns remembers, across dispatches, which agents asked to be left alone and until when.
|
||||
type cooldowns struct {
|
||||
mu sync.RWMutex
|
||||
until map[string]time.Time
|
||||
}
|
||||
|
||||
func (c *cooldowns) active(name string) bool {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return time.Now().Before(c.until[name])
|
||||
}
|
||||
|
||||
// park keeps whichever deadline is later, so a call still in flight when a longer cooldown
|
||||
// starts cannot cut it short when it finally answers.
|
||||
func (c *cooldowns) park(name string, d time.Duration) {
|
||||
until := time.Now().Add(d)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if until.After(c.until[name]) {
|
||||
c.until[name] = until
|
||||
}
|
||||
}
|
||||
|
||||
// GetAgents returns the singleton instance of Agents
|
||||
|
|
@ -77,7 +41,6 @@ func createAgents(ds model.DataStore, pluginLoader PluginLoader) *Agents {
|
|||
return &Agents{
|
||||
ds: ds,
|
||||
pluginLoader: pluginLoader,
|
||||
cooldowns: cooldowns{until: map[string]time.Time{}},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -127,19 +90,12 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
|||
} else if isPlugin {
|
||||
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
|
||||
} else {
|
||||
log.Debug("Unknown agent ignored", "name", name, "available", availableAgentNames(availablePlugins))
|
||||
log.Debug("Unknown agent ignored", "name", name)
|
||||
}
|
||||
}
|
||||
return validAgents
|
||||
}
|
||||
|
||||
// availableAgentNames returns every name accepted by the Agents config option.
|
||||
func availableAgentNames(plugins []string) []string {
|
||||
names := append(slices.Collect(maps.Keys(Map)), plugins...)
|
||||
slices.Sort(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func (a *Agents) getAgent(ea enabledAgent) Interface {
|
||||
if ea.isPlugin {
|
||||
// Try to load WASM plugin agent (if plugin loader is available)
|
||||
|
|
@ -185,7 +141,11 @@ type AlbumImageAgent struct {
|
|||
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
||||
var result []ArtistImageAgent
|
||||
for _, ea := range a.getEnabledAgentNames() {
|
||||
if retriever, ok := a.getAgent(ea).(ArtistImageRetriever); ok {
|
||||
ag := a.getAgent(ea)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if retriever, ok := ag.(ArtistImageRetriever); ok {
|
||||
result = append(result, ArtistImageAgent{Name: ea.name, Retriever: retriever})
|
||||
}
|
||||
}
|
||||
|
|
@ -197,7 +157,11 @@ func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
|||
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
|
||||
var result []AlbumImageAgent
|
||||
for _, ea := range a.getEnabledAgentNames() {
|
||||
if retriever, ok := a.getAgent(ea).(AlbumImageRetriever); ok {
|
||||
ag := a.getAgent(ea)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if retriever, ok := ag.(AlbumImageRetriever); ok {
|
||||
result = append(result, AlbumImageAgent{Name: ea.name, Retriever: retriever})
|
||||
}
|
||||
}
|
||||
|
|
@ -215,7 +179,7 @@ func (a *Agents) GetArtistMBID(ctx context.Context, id string, name string) (str
|
|||
return callAgentMethod(ctx, a, "GetArtistMBID", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistMBIDRetriever)
|
||||
if !ok {
|
||||
return "", errUnsupported
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistMBID(ctx, id, name)
|
||||
})
|
||||
|
|
@ -232,7 +196,7 @@ func (a *Agents) GetArtistURL(ctx context.Context, id, name, mbid string) (strin
|
|||
return callAgentMethod(ctx, a, "GetArtistURL", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistURLRetriever)
|
||||
if !ok {
|
||||
return "", errUnsupported
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistURL(ctx, id, name, mbid)
|
||||
})
|
||||
|
|
@ -249,7 +213,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
|||
return callAgentMethod(ctx, a, "GetArtistBiography", func(ag Interface) (string, error) {
|
||||
retriever, ok := ag.(ArtistBiographyRetriever)
|
||||
if !ok {
|
||||
return "", errUnsupported
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistBiography(ctx, id, name, mbid)
|
||||
})
|
||||
|
|
@ -268,11 +232,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||
|
||||
start := time.Now()
|
||||
attempts := newAttempts(&a.cooldowns)
|
||||
for _, enabledAgent := range a.getEnabledAgentNames() {
|
||||
if attempts.skip(enabledAgent.name) {
|
||||
continue
|
||||
}
|
||||
ag := a.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
|
|
@ -285,7 +245,6 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
continue
|
||||
}
|
||||
similar, err := retriever.GetSimilarArtists(ctx, id, name, mbid, overLimit)
|
||||
attempts.record(enabledAgent.name, err)
|
||||
if len(similar) > 0 && err == nil {
|
||||
if log.IsGreaterOrEqualTo(log.LevelTrace) {
|
||||
log.Debug(ctx, "Got Similar Artists", "agent", ag.AgentName(), "artist", name, "similar", similar, "elapsed", time.Since(start))
|
||||
|
|
@ -295,7 +254,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
|||
return similar, err
|
||||
}
|
||||
}
|
||||
return nil, attempts.noResultErr()
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]ExternalImage, error) {
|
||||
|
|
@ -309,7 +268,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
|||
return callAgentSliceMethod(ctx, a, "GetArtistImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(ArtistImageRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistImages(ctx, id, name, mbid)
|
||||
})
|
||||
|
|
@ -330,7 +289,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
|||
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetArtistTopSongs(ctx, id, artistName, mbid, overLimit)
|
||||
})
|
||||
|
|
@ -344,7 +303,7 @@ func (a *Agents) GetAlbumInfo(ctx context.Context, name, artist, mbid string) (*
|
|||
return callAgentMethod(ctx, a, "GetAlbumInfo", func(ag Interface) (*AlbumInfo, error) {
|
||||
retriever, ok := ag.(AlbumInfoRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetAlbumInfo(ctx, name, artist, mbid)
|
||||
})
|
||||
|
|
@ -358,7 +317,7 @@ func (a *Agents) GetAlbumImages(ctx context.Context, name, artist, mbid string)
|
|||
return callAgentSliceMethod(ctx, a, "GetAlbumImages", func(ag Interface) ([]ExternalImage, error) {
|
||||
retriever, ok := ag.(AlbumImageRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetAlbumImages(ctx, name, artist, mbid)
|
||||
})
|
||||
|
|
@ -369,7 +328,7 @@ func (a *Agents) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, m
|
|||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByTrack", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByTrackRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByTrack(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
|
|
@ -380,7 +339,7 @@ func (a *Agents) GetSimilarSongsByAlbum(ctx context.Context, id, name, artist, m
|
|||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByAlbum", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByAlbumRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByAlbum(ctx, id, name, artist, mbid, count)
|
||||
})
|
||||
|
|
@ -398,61 +357,16 @@ func (a *Agents) GetSimilarSongsByArtist(ctx context.Context, id, name, mbid str
|
|||
return callAgentSliceMethod(ctx, a, "GetSimilarSongsByArtist", func(ag Interface) ([]Song, error) {
|
||||
retriever, ok := ag.(SimilarSongsByArtistRetriever)
|
||||
if !ok {
|
||||
return nil, errUnsupported
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return retriever.GetSimilarSongsByArtist(ctx, id, name, mbid, count)
|
||||
})
|
||||
}
|
||||
|
||||
// agentAttempts tallies what the enabled agents did in one dispatch.
|
||||
type agentAttempts struct {
|
||||
cooldowns *cooldowns
|
||||
throttled bool
|
||||
answered bool
|
||||
}
|
||||
|
||||
func newAttempts(c *cooldowns) agentAttempts {
|
||||
return agentAttempts{cooldowns: c}
|
||||
}
|
||||
|
||||
// skip reports whether name is still cooling down, counting it as throttled for this dispatch.
|
||||
func (t *agentAttempts) skip(name string) bool {
|
||||
if !t.cooldowns.active(name) {
|
||||
return false
|
||||
}
|
||||
t.throttled = true
|
||||
return true
|
||||
}
|
||||
|
||||
// record files one agent's outcome, parking it when it asked to be retried later.
|
||||
func (t *agentAttempts) record(name string, err error) {
|
||||
switch retry, isRetryLater := errors.AsType[*RetryLaterError](err); {
|
||||
case errors.Is(err, errUnsupported):
|
||||
case isRetryLater:
|
||||
t.cooldowns.park(name, cmp.Or(retry.RetryIn, agentCooldown))
|
||||
t.throttled = true
|
||||
default:
|
||||
t.answered = true
|
||||
}
|
||||
}
|
||||
|
||||
// noResultErr tells a retryable empty dispatch (nobody answered) from a definitive miss.
|
||||
func (t *agentAttempts) noResultErr() error {
|
||||
if t.throttled && !t.answered {
|
||||
return ErrRetryLater
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
// callAgent tries each enabled agent in order until found reports a usable result.
|
||||
func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error), found func(T) bool) (T, error) {
|
||||
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
|
||||
var zero T
|
||||
start := time.Now()
|
||||
attempts := newAttempts(&agents.cooldowns)
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
if attempts.skip(enabledAgent.name) {
|
||||
continue
|
||||
}
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
|
|
@ -461,29 +375,41 @@ func callAgent[T any](ctx context.Context, agents *Agents, methodName string, fn
|
|||
break
|
||||
}
|
||||
result, err := fn(ag)
|
||||
attempts.record(enabledAgent.name, err)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if found(result) {
|
||||
if result != zero {
|
||||
log.Debug(ctx, "Got result", "method", methodName, "agent", ag.AgentName(), "elapsed", time.Since(start))
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
return zero, attempts.noResultErr()
|
||||
}
|
||||
|
||||
func callAgentMethod[T comparable](ctx context.Context, agents *Agents, methodName string, fn func(Interface) (T, error)) (T, error) {
|
||||
return callAgent(ctx, agents, methodName, fn, func(result T) bool {
|
||||
var zero T
|
||||
return result != zero
|
||||
})
|
||||
return zero, ErrNotFound
|
||||
}
|
||||
|
||||
func callAgentSliceMethod[T any](ctx context.Context, agents *Agents, methodName string, fn func(Interface) ([]T, error)) ([]T, error) {
|
||||
return callAgent(ctx, agents, methodName, fn, func(results []T) bool { return len(results) > 0 })
|
||||
start := time.Now()
|
||||
for _, enabledAgent := range agents.getEnabledAgentNames() {
|
||||
ag := agents.getAgent(enabledAgent)
|
||||
if ag == nil {
|
||||
continue
|
||||
}
|
||||
if utils.IsCtxDone(ctx) {
|
||||
break
|
||||
}
|
||||
results, err := fn(ag)
|
||||
if err != nil {
|
||||
log.Trace(ctx, "Agent method call error", "method", methodName, "agent", ag.AgentName(), "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
log.Debug(ctx, "Got results", "method", methodName, "agent", ag.AgentName(), "count", len(results), "elapsed", time.Since(start))
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var _ Interface = (*Agents)(nil)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ package agents
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/conf/configtest"
|
||||
"github.com/navidrome/navidrome/consts"
|
||||
|
|
@ -16,29 +14,6 @@ import (
|
|||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("cooldowns", func() {
|
||||
// Calls to one agent overlap, so a short cooldown can land after a long one started.
|
||||
It("keeps the longer deadline when a shorter park lands after it", func() {
|
||||
c := cooldowns{until: map[string]time.Time{}}
|
||||
|
||||
c.park("fake", time.Hour)
|
||||
c.park("fake", time.Millisecond)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
Expect(c.active("fake")).To(BeTrue())
|
||||
})
|
||||
|
||||
It("extends the deadline when the later park is longer", func() {
|
||||
c := cooldowns{until: map[string]time.Time{}}
|
||||
|
||||
c.park("fake", time.Millisecond)
|
||||
c.park("fake", time.Hour)
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
Expect(c.active("fake")).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("Agents", func() {
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
|
|
@ -59,10 +34,10 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
|
||||
It("calls the placeholder GetArtistImages", func() {
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One"}, {ID: "2", Title: "Two"}})
|
||||
mfRepo.SetData(model.MediaFiles{{ID: "1", Title: "One", MbzReleaseTrackID: "111"}, {ID: "2", Title: "Two", MbzReleaseTrackID: "222"}})
|
||||
songs, err := ag.GetArtistTopSongs(ctx, "123", "John Doe", "mb123", 2)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ConsistOf([]Song{{ID: "1", Name: "One"}, {ID: "2", Name: "Two"}}))
|
||||
Expect(songs).To(ConsistOf([]Song{{Name: "One", MBID: "111"}, {Name: "Two", MBID: "222"}}))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -92,22 +67,6 @@ var _ = Describe("Agents", func() {
|
|||
Expect(ags).ToNot(ContainElement("disabled"))
|
||||
})
|
||||
|
||||
Describe("availableAgentNames", func() {
|
||||
It("combines built-in agents with the given plugins", func() {
|
||||
names := availableAgentNames([]string{"apple-music"})
|
||||
Expect(names).To(ContainElements("apple-music", LocalAgentName, "fake", "empty"))
|
||||
})
|
||||
|
||||
It("returns the names sorted", func() {
|
||||
names := availableAgentNames([]string{"zz-plugin", "aa-plugin"})
|
||||
Expect(slices.IsSorted(names)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("works when there are no plugins", func() {
|
||||
Expect(availableAgentNames(nil)).To(ContainElement(LocalAgentName))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistMBID", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistMBID(ctx, "123", "test")).To(Equal("mbid"))
|
||||
|
|
@ -201,102 +160,6 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
})
|
||||
|
||||
Describe("cooldown", func() {
|
||||
It("skips an agent that returned RetryLaterError until the deadline", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
// Immediately after: agent is skipped, not called
|
||||
mock.Err = nil
|
||||
calls := mock.Calls
|
||||
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls))
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
// Providers that throttle without saying for how long (Last.fm sends no delay at all)
|
||||
// must still be parked, or the aggregate keeps calling them on every request.
|
||||
It("parks an agent that asked to be retried without a delay", func() {
|
||||
mock.Err = ErrRetryLater
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
mock.Err = nil
|
||||
calls := mock.Calls
|
||||
_, err = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls), "the default cooldown must outlast the request")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("calls the agent again once the cooldown expires", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: 10 * time.Millisecond}
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
|
||||
mock.Err = nil
|
||||
Eventually(func() (string, error) {
|
||||
return ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
}, 5*time.Second, 10*time.Millisecond).Should(Equal("bio"))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound, not ErrRetryLater, when agents failed for other reasons", func() {
|
||||
mock.Err = errors.New("boom")
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
// ErrRetryLater tells the caller "nobody answered, do not cache this". A definitive
|
||||
// answer from any other agent is an answer, throttled peer or not.
|
||||
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
|
||||
other := &mockAgent{Err: ErrNotFound}
|
||||
Register("fake2", func(model.DataStore) Interface { return other })
|
||||
conf.Server.Agents = "fake,fake2"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
|
||||
// The cooldown was still recorded for the throttled agent
|
||||
calls := mock.Calls
|
||||
_, _ = ag.GetArtistBiography(ctx, "id", "name", "mbid")
|
||||
Expect(mock.Calls).To(Equal(calls))
|
||||
})
|
||||
|
||||
It("returns ErrNotFound when another agent answered with an empty slice", func() {
|
||||
empty := &testImageAgent{Name: "emptyImages"}
|
||||
Register("emptyImages", func(model.DataStore) Interface { return empty })
|
||||
conf.Server.Agents = "fake,emptyImages"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetArtistImages(ctx, "123", "test", "mb123")
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns ErrRetryLater from GetSimilarArtists when only cooling agents remain", func() {
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
|
||||
other := &mockAgent{Err: ErrNotFound}
|
||||
Register("fake2", func(model.DataStore) Interface { return other })
|
||||
conf.Server.Agents = "fake,fake2"
|
||||
ag = createAgents(ds, nil)
|
||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||
|
||||
_, err := ag.GetSimilarArtists(ctx, "123", "test", "mb123", 2)
|
||||
Expect(errors.Is(err, ErrNotFound)).To(BeTrue())
|
||||
Expect(errors.Is(err, ErrRetryLater)).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("GetArtistImages", func() {
|
||||
It("returns on first match", func() {
|
||||
Expect(ag.GetArtistImages(ctx, "123", "test", "mb123")).To(Equal([]ExternalImage{{
|
||||
|
|
@ -560,9 +423,8 @@ var _ = Describe("Agents", func() {
|
|||
})
|
||||
|
||||
type mockAgent struct {
|
||||
Args []any
|
||||
Err error
|
||||
Calls int
|
||||
Args []any
|
||||
Err error
|
||||
}
|
||||
|
||||
func (a *mockAgent) AgentName() string {
|
||||
|
|
@ -587,7 +449,6 @@ func (a *mockAgent) GetArtistURL(_ context.Context, id, name, mbid string) (stri
|
|||
|
||||
func (a *mockAgent) GetArtistBiography(_ context.Context, id, name, mbid string) (string, error) {
|
||||
a.Args = []any{id, name, mbid}
|
||||
a.Calls++
|
||||
if a.Err != nil {
|
||||
return "", a.Err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,6 @@ package agents
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gohugoio/hashstructure"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
|
|
@ -55,49 +52,9 @@ func (s Song) Equals(other Song) bool {
|
|||
return h1 == h2
|
||||
}
|
||||
|
||||
// ErrNotFound means the provider answered and had nothing. Return the underlying error
|
||||
// for a fault instead, or callers that back off on faults will treat it as definitive.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrRetryLater is the zero-delay RetryLaterError: the provider is temporarily unavailable
|
||||
// or throttling us, but did not say for how long. Both errors.Is(err, ErrRetryLater) and
|
||||
// errors.AsType[*RetryLaterError] match it and every delay-carrying variant.
|
||||
// Treat it as immutable; build a new RetryLaterError to name a delay.
|
||||
var ErrRetryLater = &RetryLaterError{}
|
||||
|
||||
// RetryLaterError asks callers to back off, optionally for the delay the provider requested.
|
||||
type RetryLaterError struct {
|
||||
RetryIn time.Duration
|
||||
}
|
||||
|
||||
func (e *RetryLaterError) Error() string {
|
||||
if e.RetryIn > 0 {
|
||||
return fmt.Sprintf("retry later (in %s)", e.RetryIn)
|
||||
}
|
||||
return "retry later"
|
||||
}
|
||||
|
||||
func (e *RetryLaterError) Is(target error) bool {
|
||||
_, ok := target.(*RetryLaterError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// MaxRetryIn caps a delay parsed from a provider, so a bogus value cannot park it indefinitely.
|
||||
const MaxRetryIn = time.Hour
|
||||
const maxRetryInSeconds = int(MaxRetryIn / time.Second)
|
||||
|
||||
// ParseRetryIn reads a provider's delay given in seconds, from a header or a plugin token.
|
||||
// Anything unparseable or non-positive means unspecified.
|
||||
func ParseRetryIn(seconds string) time.Duration {
|
||||
// Clamp in seconds: scaling first would wrap a huge value past int64 nanoseconds,
|
||||
// turning "wait an age" into a fraction of a second. Parse at a fixed width so the
|
||||
// cap holds on the 32-bit targets we ship, where a plain Atoi would overflow first.
|
||||
secs, err := strconv.ParseInt(seconds, 10, 64)
|
||||
if err != nil || secs <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(min(secs, int64(maxRetryInSeconds))) * time.Second
|
||||
}
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
// AlbumInfoRetriever provides album info (no images)
|
||||
type AlbumInfoRetriever interface {
|
||||
|
|
|
|||
|
|
@ -1,42 +1,27 @@
|
|||
package agents_test
|
||||
package agents
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/navidrome/navidrome/core/agents"
|
||||
"github.com/navidrome/navidrome/core/scrobbler"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("RetryLaterError", func() {
|
||||
It("matches the ErrRetryLater sentinel via errors.Is", func() {
|
||||
err := &agents.RetryLaterError{RetryIn: 30 * time.Second}
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
var _ = Describe("Song.Equals", func() {
|
||||
base := Song{ID: "1", Name: "S", Artists: []Artist{{ID: "x", Name: "A"}}}
|
||||
It("true for identical songs incl Artists", func() {
|
||||
Expect(base.Equals(base)).To(BeTrue())
|
||||
})
|
||||
|
||||
It("matches through errors.Join and wrapping", func() {
|
||||
err := fmt.Errorf("calling LB: %w", errors.Join(errors.New("http 429"), &agents.RetryLaterError{}))
|
||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeTrue())
|
||||
It("false when Artists differ", func() {
|
||||
other := base
|
||||
other.Artists = []Artist{{ID: "y", Name: "B"}}
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("exposes the delay through the wrapped error", func() {
|
||||
err := errors.Join(errors.New("http 429"), &agents.RetryLaterError{RetryIn: 42 * time.Second})
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](err)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(Equal(42 * time.Second))
|
||||
It("false when a scalar differs", func() {
|
||||
other := base
|
||||
other.Name = "T"
|
||||
Expect(base.Equals(other)).To(BeFalse())
|
||||
})
|
||||
|
||||
It("matches the sentinel too, reporting no delay", func() {
|
||||
retry, ok := errors.AsType[*agents.RetryLaterError](agents.ErrRetryLater)
|
||||
Expect(ok).To(BeTrue())
|
||||
Expect(retry.RetryIn).To(BeZero())
|
||||
})
|
||||
|
||||
It("is the same sentinel as scrobbler.ErrRetryLater", func() {
|
||||
Expect(errors.Is(scrobbler.ErrRetryLater, agents.ErrRetryLater)).To(BeTrue())
|
||||
Expect(errors.Is(&agents.RetryLaterError{}, scrobbler.ErrRetryLater)).To(BeTrue())
|
||||
It("true when both have empty Artists and equal scalars", func() {
|
||||
a := Song{ID: "1", Name: "S"}
|
||||
Expect(a.Equals(a)).To(BeTrue())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import (
|
|||
|
||||
"github.com/Masterminds/squirrel"
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/persistence"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
)
|
||||
|
||||
const LocalAgentName = "local"
|
||||
|
|
@ -24,7 +22,7 @@ func (p *localAgent) AgentName() string {
|
|||
}
|
||||
|
||||
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||
Sort: "playCount",
|
||||
Order: "desc",
|
||||
Max: count,
|
||||
|
|
@ -39,51 +37,14 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return songsFrom(top), nil
|
||||
}
|
||||
|
||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||
seed, err := p.ds.MediaFile().Get(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var result []Song
|
||||
for _, s := range top {
|
||||
result = append(result, Song{
|
||||
Name: s.Title,
|
||||
MBID: s.MbzReleaseTrackID,
|
||||
})
|
||||
}
|
||||
// Tag ids derive from (name, value), so the seed's genre ids need no extra query.
|
||||
genreIDs := slice.Map(seed.Tags.Flatten(model.TagGenre), func(t model.Tag) string { return t.ID })
|
||||
if len(genreIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||
Filters: squirrel.And{
|
||||
persistence.SongGenres.ByID(genreIDs),
|
||||
squirrel.Eq{"missing": false},
|
||||
},
|
||||
Max: count + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make(model.MediaFiles, 0, len(candidates))
|
||||
for _, s := range candidates {
|
||||
if s.ID == id {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
if len(filtered) >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return songsFrom(filtered), nil
|
||||
}
|
||||
|
||||
func songsFrom(mfs model.MediaFiles) []Song {
|
||||
if len(mfs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return slice.Map(mfs, func(mf model.MediaFile) Song {
|
||||
return Song{ID: mf.ID, Name: mf.Title}
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/navidrome/navidrome/model"
|
||||
"github.com/navidrome/navidrome/tests"
|
||||
"github.com/navidrome/navidrome/utils/slice"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("localAgent GetSimilarSongsByTrack", func() {
|
||||
var ds *tests.MockDataStore
|
||||
var mfRepo *tests.MockMediaFileRepo
|
||||
var agent *localAgent
|
||||
var ctx context.Context
|
||||
|
||||
BeforeEach(func() {
|
||||
ctx = context.Background()
|
||||
mfRepo = &tests.MockMediaFileRepo{}
|
||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||
agent = &localAgent{ds: ds}
|
||||
})
|
||||
|
||||
It("excludes the seed track from its own similars", func() {
|
||||
seed := model.MediaFile{ID: "seed-1", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "rel-1", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// SetData keys by ID; a duplicate "seed-1" entry would clobber the real seed.
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-1", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
names := slice.Map(songs, func(s Song) string { return s.Name })
|
||||
Expect(names).ToNot(ContainElement("Seed"))
|
||||
})
|
||||
|
||||
// The mock ignores QueryOptions.Filters, so assert the predicate itself: otherwise this spec
|
||||
// would pass just as well with no genre filter at all.
|
||||
It("queries the indexed genre join for the seed's own genres, skipping missing files", func() {
|
||||
rock := model.NewTag(model.TagGenre, "Rock")
|
||||
seed := model.MediaFile{ID: "seed-4", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
_, err := agent.GetSimilarSongsByTrack(ctx, "seed-4", "Seed", "", "", 10)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
|
||||
sql, args, sqlErr := mfRepo.Options.Filters.ToSql()
|
||||
Expect(sqlErr).ToNot(HaveOccurred())
|
||||
Expect(sql).To(ContainSubstring("media_file_tags"), "must use the indexed join, not a json_tree scan")
|
||||
Expect(sql).To(ContainSubstring("missing"))
|
||||
Expect(args).To(ContainElement(false), "must exclude missing files, not select them")
|
||||
Expect(args).To(ContainElement(rock.ID), "must filter on the seed's own genre tag id")
|
||||
Expect(args).ToNot(ContainElement(model.NewTag(model.TagGenre, "Jazz").ID))
|
||||
})
|
||||
|
||||
It("returns the library id so the matcher can resolve the song", func() {
|
||||
seed := model.MediaFile{ID: "seed-3", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
// Without the id the matcher falls through to its MBID/title phases and resolves nothing,
|
||||
// so the local fallback silently returns an empty mix.
|
||||
related := model.MediaFile{ID: "rel-3", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-3", "Seed", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(ContainElement(Song{ID: "rel-3", Name: "Related"}))
|
||||
})
|
||||
|
||||
It("asks for one extra candidate so dropping the seed still fills the count", func() {
|
||||
// The mock returns rows sorted by id, so the seed comes first and would consume the only
|
||||
// slot if the query did not over-fetch.
|
||||
seed := model.MediaFile{ID: "a-seed", Title: "Seed", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
related := model.MediaFile{ID: "b-rel", Title: "Related", Tags: model.Tags{model.TagGenre: []string{"Rock"}}}
|
||||
mfRepo.SetData(model.MediaFiles{seed, related})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "a-seed", "Seed", "", "", 1)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(HaveLen(1))
|
||||
Expect(songs[0].Name).To(Equal("Related"))
|
||||
})
|
||||
|
||||
It("returns nil when the seed track has no genres", func() {
|
||||
seed := model.MediaFile{ID: "seed-2", Title: "NoGenre"}
|
||||
mfRepo.SetData(model.MediaFiles{seed})
|
||||
|
||||
songs, err := agent.GetSimilarSongsByTrack(ctx, "seed-2", "NoGenre", "", "", 10)
|
||||
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(songs).To(BeEmpty())
|
||||
// Without the early return an empty tag filter would scan the whole library.
|
||||
Expect(mfRepo.Options).To(Equal(model.QueryOptions{}), "must not query at all")
|
||||
})
|
||||
})
|
||||
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