mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 22:28:41 +01:00
Compare commits
21 commits
master
...
user-avata
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae8b736d07 | ||
|
|
56ec411cae | ||
|
|
114208e6d6 | ||
|
|
41d7d1d48b | ||
|
|
755ae5106f | ||
|
|
7b225f530f | ||
|
|
e405723c43 | ||
|
|
89d8fbbd13 | ||
|
|
c5303e06e1 | ||
|
|
c5a29983a5 | ||
|
|
669d9216ec | ||
|
|
d57bda78f8 | ||
|
|
e7f3245acd | ||
|
|
1421604d6a | ||
|
|
16c62e075e | ||
|
|
61bb68fc6b | ||
|
|
cac170781b | ||
|
|
15d6a2a728 | ||
|
|
ab2c77f7b2 | ||
|
|
42008a1b88 | ||
|
|
962910f0de |
808 changed files with 13741 additions and 30202 deletions
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,74 +1,98 @@
|
||||||
name: Bug report
|
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.
|
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["triage"]
|
labels: ["bug", "triage"]
|
||||||
#assignees:
|
#assignees:
|
||||||
# - deluan
|
# - deluan
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
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
|
- type: checkboxes
|
||||||
id: requirements
|
id: requirements
|
||||||
attributes:
|
attributes:
|
||||||
label: "I confirm that:"
|
label: "I confirm that:"
|
||||||
options:
|
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
|
required: true
|
||||||
|
- label: I'm using the latest version (your issue may have been fixed already)
|
||||||
|
required: false
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Navidrome version
|
label: 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.
|
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected behavior and why
|
label: Current Behavior
|
||||||
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.
|
description: A concise description of what you're experiencing.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Expected Behavior
|
||||||
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.
|
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:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: env
|
id: env
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant environment
|
label: Environment
|
||||||
description: >-
|
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.
|
examples:
|
||||||
validations:
|
- **OS**: Ubuntu 20.04
|
||||||
required: false
|
- **Browser**: Chrome 110.0.5481.177 on Windows 11
|
||||||
- type: textarea
|
- **Client**: DSub 5.5.1
|
||||||
id: config-logs
|
value: |
|
||||||
|
- OS:
|
||||||
|
- Browser:
|
||||||
|
- Client:
|
||||||
|
render: markdown
|
||||||
|
- type: dropdown
|
||||||
|
id: distribution
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant configuration and logs
|
label: How Navidrome is installed?
|
||||||
description: >-
|
multiple: false
|
||||||
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).
|
options:
|
||||||
|
- Docker
|
||||||
|
- Binary (from downloads page)
|
||||||
|
- Package
|
||||||
|
- Built from sources
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional-context
|
id: config
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots or additional context
|
label: Configuration
|
||||||
description: Add screenshots, recordings, links, or other observations that help explain the problem. Remove private information from attachments as well.
|
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.
|
||||||
validations:
|
render: toml
|
||||||
required: false
|
- 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
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
||||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Ideas and behavior changes
|
- name: Ideas for new features
|
||||||
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
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
|
- name: Support requests
|
||||||
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
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.
|
||||||
|
|
|
||||||
21
.github/pull_request_template.md
vendored
21
.github/pull_request_template.md
vendored
|
|
@ -1,13 +1,8 @@
|
||||||
### Description
|
### 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. -->
|
<!-- Please provide a clear and concise description of what this PR does and why it is needed. -->
|
||||||
|
|
||||||
**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. -->
|
|
||||||
|
|
||||||
### Related Issues
|
### 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
|
### Type of Change
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
|
|
@ -17,29 +12,27 @@
|
||||||
- [ ] Other (please describe):
|
- [ ] Other (please describe):
|
||||||
|
|
||||||
### Checklist
|
### 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
|
- [ ] My code follows the project’s coding style
|
||||||
- [ ] I have tested the changes locally
|
- [ ] I have tested the changes locally
|
||||||
- [ ] I have added or updated documentation as needed
|
- [ ] I have added or updated documentation as needed
|
||||||
- [ ] I have added tests that prove my fix/feature works (or explain why not)
|
- [ ] I have added tests that prove my fix/feature works (or explain why not)
|
||||||
- [ ] All existing and new tests pass
|
- [ ] 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
|
### 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)
|
### Screenshots / Demos (if applicable)
|
||||||
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
|
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
|
||||||
|
|
||||||
### Additional Notes
|
### 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:**
|
**Tips for Contributors:**
|
||||||
- Be concise but thorough.
|
- Be concise but thorough.
|
||||||
- If your PR is large, consider breaking it into smaller PRs within the agreed design and scope.
|
- If your PR is large, consider breaking it into smaller PRs.
|
||||||
- Review timing depends on maintainer availability; please do not tag the maintainer for faster review.
|
- 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.
|
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
|
||||||
-->
|
-->
|
||||||
38
.github/workflows/pipeline.yml
vendored
38
.github/workflows/pipeline.yml
vendored
|
|
@ -79,7 +79,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: ${{ steps.golangci-version.outputs.version }}
|
version: ${{ steps.golangci-version.outputs.version }}
|
||||||
problem-matchers: true
|
problem-matchers: true
|
||||||
args: --timeout 3m
|
args: --timeout 2m
|
||||||
|
|
||||||
- name: Run go goimports
|
- 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$'`
|
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
|
||||||
|
|
@ -92,23 +92,8 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Resolve OpenAPI tool versions
|
|
||||||
id: api-tools
|
|
||||||
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache OpenAPI tools
|
|
||||||
uses: actions/cache@v6
|
|
||||||
with:
|
|
||||||
path: bin
|
|
||||||
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
|
||||||
|
|
||||||
- name: Lint OpenAPI spec
|
|
||||||
run: make api-lint
|
|
||||||
|
|
||||||
- name: Run go generate
|
- name: Run go generate
|
||||||
run: |
|
run: go generate ./...
|
||||||
make api-gen
|
|
||||||
go generate ./...
|
|
||||||
- name: Verify no changes from go generate
|
- name: Verify no changes from go generate
|
||||||
run: |
|
run: |
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
|
|
@ -117,12 +102,6 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Check for breaking OpenAPI changes
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
run: |
|
|
||||||
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
|
||||||
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
|
||||||
|
|
||||||
validate-migrations:
|
validate-migrations:
|
||||||
name: Validate DB migrations
|
name: Validate DB migrations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -519,18 +498,13 @@ jobs:
|
||||||
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
push-manifest-dockerhub:
|
push-manifest-dockerhub:
|
||||||
name: Push to Docker Hub (${{ matrix.repository }})
|
name: Push to Docker Hub
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
needs: [build, check-push-enabled]
|
needs: [build, check-push-enabled]
|
||||||
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
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:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
||||||
|
|
@ -546,7 +520,7 @@ jobs:
|
||||||
id: docker
|
id: docker
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
hub_repository: ${{ matrix.repository }}
|
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
||||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
|
|
@ -563,7 +537,7 @@ jobs:
|
||||||
|
|
||||||
- name: Inspect image in Docker Hub
|
- name: Inspect image in Docker Hub
|
||||||
run: |
|
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:
|
cleanup-digests:
|
||||||
name: Cleanup digest artifacts
|
name: Cleanup digest artifacts
|
||||||
|
|
@ -575,7 +549,7 @@ jobs:
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
for artifact in $(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
for artifact in $(gh api repos/${{ github.repository }}/actions/artifacts | jq -r '.artifacts[] | select(.name | startswith("digests-")) | .id'); do
|
||||||
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
gh api --method DELETE repos/${{ github.repository }}/actions/artifacts/$artifact
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ linters:
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
- containedctx
|
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
|
|
@ -58,9 +57,6 @@ linters:
|
||||||
- gosec
|
- gosec
|
||||||
path: _test\.go
|
path: _test\.go
|
||||||
text: "G703"
|
text: "G703"
|
||||||
- path: _test\.go
|
|
||||||
linters:
|
|
||||||
- containedctx
|
|
||||||
- path-except: 'db/migrations/'
|
- path-except: 'db/migrations/'
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
|
|
||||||
111
CONTRIBUTING.md
111
CONTRIBUTING.md
|
|
@ -1,66 +1,93 @@
|
||||||
# Navidrome Contribution Guide
|
# 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.
|
|
||||||
|
|
|
||||||
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
|
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/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= navidrome/navidrome:develop
|
DOCKER_TAG ?= deluan/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
GOLANGCI_LINT_VERSION ?= v2.13.2
|
||||||
VACUUM_VERSION ?= v0.30.6
|
|
||||||
OAPI_CODEGEN_VERSION ?= v2.8.0
|
|
||||||
OASDIFF_VERSION ?= v1.32.1
|
|
||||||
API_DIFF_BASE ?= origin/master
|
|
||||||
|
|
||||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
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
|
fi
|
||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
|
||||||
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
|
||||||
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
|
||||||
echo "Installing OpenAPI tools..."; \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
|
||||||
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
|
||||||
rm -f bin/.api-tools-* && touch $$STAMP; \
|
|
||||||
fi
|
|
||||||
.PHONY: install-api-tools
|
|
||||||
|
|
||||||
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
|
||||||
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
|
||||||
.PHONY: api-lint
|
|
||||||
|
|
||||||
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
|
||||||
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
|
||||||
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
|
||||||
.PHONY: api-bundle
|
|
||||||
|
|
||||||
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
|
||||||
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
|
||||||
.PHONY: api-gen
|
|
||||||
|
|
||||||
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
|
||||||
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
|
||||||
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
|
||||||
if [ -z "$$BASE" ]; then \
|
|
||||||
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
|
||||||
BASE=$(API_DIFF_BASE); \
|
|
||||||
fi; \
|
|
||||||
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
|
||||||
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
|
||||||
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
|
||||||
else \
|
|
||||||
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
|
||||||
fi
|
|
||||||
.PHONY: api-diff
|
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
@ -154,7 +111,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
gen: check_go_env ##@Development Run go generate for code generation
|
||||||
go generate ./...
|
go generate ./...
|
||||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
cd plugins/cmd/ndpgen && go run . -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 .
|
--output "./binaries" --target binary .
|
||||||
.PHONY: docker-build
|
.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 "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 "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
|
@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://github.com/navidrome/navidrome/releases)
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
[](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://discord.gg/xh7j7yF)
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
[](https://www.reddit.com/r/navidrome/)
|
||||||
[](CODE_OF_CONDUCT.md)
|
[](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/)
|
## [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).
|
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||||
If you need/want a new feature or think of any way to improve Navidrome, please share it in
|
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
||||||
[Discussions](https://github.com/navidrome/navidrome/discussions/categories/ideas) 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
|
||||||
[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
|
|
||||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
||||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
[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
|
## Cloud Hosting
|
||||||
|
|
||||||
Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
|
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
||||||
|
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||||
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||||
<br>
|
|
||||||
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
|
||||||
<br>
|
|
||||||
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[](https://www.pikapods.com/pods?run=navidrome)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ type deezerAgent struct {
|
||||||
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
||||||
agent := &deezerAgent{
|
agent := &deezerAgent{
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
languages: conf.Server().Deezer.Languages,
|
languages: conf.Server.Deezer.Languages,
|
||||||
}
|
}
|
||||||
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||||
|
|
@ -192,7 +192,7 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server().Deezer.Enabled {
|
if conf.Server.Deezer.Enabled {
|
||||||
agents.Register(deezerAgentName, deezerConstructor)
|
agents.Register(deezerAgentName, deezerConstructor)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package deezer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestDeezer(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Deezer Test Suite")
|
RunSpecs(t, "Deezer Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -22,12 +23,13 @@ var _ = Describe("deezerAgent", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
conf.Update(func(c *conf.Options) { c.Deezer.Enabled = true })
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.Deezer.Enabled = true
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("deezerConstructor", func() {
|
Describe("deezerConstructor", func() {
|
||||||
It("uses configured languages", func() {
|
It("uses configured languages", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Deezer.Languages = []string{"pt", "en"} })
|
conf.Server.Deezer.Languages = []string{"pt", "en"}
|
||||||
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
|
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
|
||||||
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
||||||
})
|
})
|
||||||
|
|
@ -177,7 +179,7 @@ var _ = Describe("deezerAgent", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
setupAgent := func(languages []string) {
|
setupAgent := func(languages []string) {
|
||||||
conf.Update(func(c *conf.Options) { c.Deezer.Languages = languages })
|
conf.Server.Deezer.Languages = languages
|
||||||
agent = &deezerAgent{
|
agent = &deezerAgent{
|
||||||
dataStore: &tests.MockDataStore{},
|
dataStore: &tests.MockDataStore{},
|
||||||
client: newClient(httpClient),
|
client: newClient(httpClient),
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
|
||||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
metadata := metadata.New(path, info)
|
metadata := metadata.New(path, info)
|
||||||
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
return new(metadata.ToMediaFile(1, "folderID"))
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,14 @@ type lastfmAgent struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||||
if !conf.Server().LastFM.Enabled || conf.Server().LastFM.ApiKey == "" || conf.Server().LastFM.Secret == "" {
|
if !conf.Server.LastFM.Enabled || conf.Server.LastFM.ApiKey == "" || conf.Server.LastFM.Secret == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l := &lastfmAgent{
|
l := &lastfmAgent{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
languages: conf.Server().LastFM.Languages,
|
languages: conf.Server.LastFM.Languages,
|
||||||
apiKey: conf.Server().LastFM.ApiKey,
|
apiKey: conf.Server.LastFM.ApiKey,
|
||||||
secret: conf.Server().LastFM.Secret,
|
secret: conf.Server.LastFM.Secret,
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
|
|
@ -241,10 +241,6 @@ func (l *lastfmAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, arti
|
||||||
var (
|
var (
|
||||||
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
|
||||||
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
|
||||||
|
|
||||||
// Not a RetryLaterError on purpose: parking the agent would also stall its API-backed
|
|
||||||
// methods, which the page block does not affect.
|
|
||||||
errNoArtistPage = errors.New("no artist image in Last.fm page")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
|
||||||
|
|
@ -271,9 +267,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
|
||||||
var res []agents.ExternalImage
|
var res []agents.ExternalImage
|
||||||
n := cascadia.Query(node, artistOpenGraphQuery)
|
n := cascadia.Query(node, artistOpenGraphQuery)
|
||||||
if n == nil {
|
if n == nil {
|
||||||
// A real artist page always has og:image; its absence means a bot challenge or a redesign.
|
return res, nil
|
||||||
log.Warn(ctx, "Last.fm did not return a usable artist page", "name", name, "url", a.URL)
|
|
||||||
return nil, errNoArtistPage
|
|
||||||
}
|
}
|
||||||
for _, attr := range n.Attr {
|
for _, attr := range n.Attr {
|
||||||
if attr.Key != "content" {
|
if attr.Key != "content" {
|
||||||
|
|
@ -354,7 +348,7 @@ func (l *lastfmAgent) callTrackGetSimilar(ctx context.Context, name, artist stri
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
|
func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
|
||||||
if conf.Server().LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
|
if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
|
||||||
return track.Participants[role][0].Name
|
return track.Participants[role][0].Name
|
||||||
}
|
}
|
||||||
return displayName
|
return displayName
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -31,16 +32,15 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
conf.Update(func(c *conf.Options) {
|
DeferCleanup(configtest.SetupConfig())
|
||||||
c.LastFM.Enabled = true
|
conf.Server.LastFM.Enabled = true
|
||||||
c.LastFM.ApiKey = "123"
|
conf.Server.LastFM.ApiKey = "123"
|
||||||
c.LastFM.Secret = "secret"
|
conf.Server.LastFM.Secret = "secret"
|
||||||
})
|
|
||||||
})
|
})
|
||||||
Describe("lastFMConstructor", func() {
|
Describe("lastFMConstructor", func() {
|
||||||
When("Agent is properly configured", func() {
|
When("Agent is properly configured", func() {
|
||||||
It("uses configured api key and languages", func() {
|
It("uses configured api key and languages", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Languages = []string{"pt", "en"} })
|
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
||||||
agent := lastFMConstructor(ds)
|
agent := lastFMConstructor(ds)
|
||||||
Expect(agent.apiKey).To(Equal("123"))
|
Expect(agent.apiKey).To(Equal("123"))
|
||||||
Expect(agent.secret).To(Equal("secret"))
|
Expect(agent.secret).To(Equal("secret"))
|
||||||
|
|
@ -49,19 +49,19 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
})
|
})
|
||||||
When("Agent is disabled", func() {
|
When("Agent is disabled", func() {
|
||||||
It("returns nil", func() {
|
It("returns nil", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Enabled = false })
|
conf.Server.LastFM.Enabled = false
|
||||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
Expect(lastFMConstructor(ds)).To(BeNil())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
When("ApiKey is empty", func() {
|
When("ApiKey is empty", func() {
|
||||||
It("returns nil", func() {
|
It("returns nil", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.ApiKey = "" })
|
conf.Server.LastFM.ApiKey = ""
|
||||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
Expect(lastFMConstructor(ds)).To(BeNil())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
When("Secret is empty", func() {
|
When("Secret is empty", func() {
|
||||||
It("returns nil", func() {
|
It("returns nil", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Secret = "" })
|
conf.Server.LastFM.Secret = ""
|
||||||
Expect(lastFMConstructor(ds)).To(BeNil())
|
Expect(lastFMConstructor(ds)).To(BeNil())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -121,7 +121,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns content in first language when available (1 API call)", func() {
|
It("returns content in first language when available (1 API call)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Languages = []string{"pt", "en"} })
|
conf.Server.LastFM.Languages = []string{"pt", "en"}
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||||
|
|
||||||
|
|
@ -138,7 +138,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to second language when first returns empty (2 API calls)", func() {
|
It("falls back to second language when first returns empty (2 API calls)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Languages = []string{"ja", "en"} })
|
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||||
|
|
||||||
|
|
@ -159,7 +159,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns ErrNotFound when all languages return empty", func() {
|
It("returns ErrNotFound when all languages return empty", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Languages = []string{"ja", "xx"} })
|
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||||
|
|
||||||
|
|
@ -186,7 +186,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to second language when first returns empty description (2 API calls)", func() {
|
It("falls back to second language when first returns empty description (2 API calls)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Languages = []string{"ja", "en"} })
|
conf.Server.LastFM.Languages = []string{"ja", "en"}
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||||
|
|
||||||
|
|
@ -208,7 +208,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns album without description when all languages return empty", func() {
|
It("returns album without description when all languages return empty", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.Languages = []string{"ja", "xx"} })
|
conf.Server.LastFM.Languages = []string{"ja", "xx"}
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
agent.client = newClient("API_KEY", "SECRET", httpClient)
|
||||||
|
|
||||||
|
|
@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
var track *model.MediaFile
|
var track *model.MediaFile
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
|
|
@ -412,7 +412,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
|
|
||||||
When("ScrobbleFirstArtistOnly is true", func() {
|
When("ScrobbleFirstArtistOnly is true", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.ScrobbleFirstArtistOnly = true })
|
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
||||||
})
|
})
|
||||||
|
|
||||||
It("uses only the first artist", func() {
|
It("uses only the first artist", func() {
|
||||||
|
|
@ -454,7 +454,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
|
|
||||||
When("ScrobbleFirstArtistOnly is true", func() {
|
When("ScrobbleFirstArtistOnly is true", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) { c.LastFM.ScrobbleFirstArtistOnly = true })
|
conf.Server.LastFM.ScrobbleFirstArtistOnly = true
|
||||||
})
|
})
|
||||||
|
|
||||||
It("uses only the first artist", func() {
|
It("uses only the first artist", func() {
|
||||||
|
|
@ -648,41 +648,18 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
Expect(images).To(BeEmpty())
|
Expect(images).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("errors when the page has no meta tags", func() {
|
It("returns empty list if page has no meta tags", func() {
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
||||||
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(err).To(MatchError(errNoArtistPage))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("errors when Last.fm serves a bot challenge page", func() {
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
images, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||||
Expect(err).To(MatchError(errNoArtistPage))
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(images).To(BeEmpty())
|
Expect(images).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not park the agent: the failure is not a retry-later", func() {
|
|
||||||
// A RetryLaterError would cool down the agent's API-backed methods too.
|
|
||||||
fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
|
|
||||||
|
|
||||||
fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.challenge.html")
|
|
||||||
httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
|
||||||
Expect(errors.Is(err, agents.ErrRetryLater)).To(BeFalse())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns error if API call fails", func() {
|
It("returns error if API call fails", func() {
|
||||||
apiClient.Err = errors.New("api error")
|
apiClient.Err = errors.New("api error")
|
||||||
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
_, err := agent.GetArtistImages(ctx, "123", "U2", "")
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,8 @@ type Router struct {
|
||||||
func NewRouter(ds model.DataStore) *Router {
|
func NewRouter(ds model.DataStore) *Router {
|
||||||
r := &Router{
|
r := &Router{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
apiKey: conf.Server().LastFM.ApiKey,
|
apiKey: conf.Server.LastFM.ApiKey,
|
||||||
secret: conf.Server().LastFM.Secret,
|
secret: conf.Server.LastFM.Secret,
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
|
|
@ -132,7 +132,7 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) {
|
||||||
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
func (s *Router) fetchSessionKey(ctx context.Context, uid, token string) error {
|
||||||
sessionKey, err := s.client.getSession(ctx, token)
|
sessionKey, err := s.client.getSession(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid,
|
log.Error(ctx, "Could not fetch LastFM session key", "userId", uid, "token", token,
|
||||||
"requestId", middleware.GetReqID(ctx), err)
|
"requestId", middleware.GetReqID(ctx), err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
storedSessionKey := func(userID string) string {
|
storedSessionKey := func(userID string) string {
|
||||||
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
key, _ := userProps.Get(userID, sessionKeyProperty)
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,6 @@ type client struct {
|
||||||
hc httpDoer
|
hc httpDoer
|
||||||
}
|
}
|
||||||
|
|
||||||
// escapePlus works around Last.fm decoding artist.* and track.* params twice, turning "+" into a space.
|
|
||||||
// album.getInfo decodes only once, so it must not use this.
|
|
||||||
func escapePlus(s string) string {
|
|
||||||
return strings.ReplaceAll(s, "+", "%2B")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
func (c *client) albumGetInfo(ctx context.Context, name string, artist string, mbid string, lang string) (*Album, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "album.getInfo")
|
params.Add("method", "album.getInfo")
|
||||||
|
|
@ -72,7 +66,7 @@ func (c *client) albumGetInfo(ctx context.Context, name string, artist string, m
|
||||||
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*Artist, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getInfo")
|
params.Add("method", "artist.getInfo")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("lang", lang)
|
params.Add("lang", lang)
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -84,7 +78,7 @@ func (c *client) artistGetInfo(ctx context.Context, name string, lang string) (*
|
||||||
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (*SimilarArtists, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getSimilar")
|
params.Add("method", "artist.getSimilar")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -96,7 +90,7 @@ func (c *client) artistGetSimilar(ctx context.Context, name string, limit int) (
|
||||||
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int) (*TopTracks, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "artist.getTopTracks")
|
params.Add("method", "artist.getTopTracks")
|
||||||
params.Add("artist", escapePlus(name))
|
params.Add("artist", name)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -108,8 +102,8 @@ func (c *client) artistGetTopTracks(ctx context.Context, name string, limit int)
|
||||||
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
func (c *client) trackGetSimilar(ctx context.Context, name, artist string, limit int) (*SimilarTracks, error) {
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Add("method", "track.getSimilar")
|
params.Add("method", "track.getSimilar")
|
||||||
params.Add("track", escapePlus(name))
|
params.Add("track", name)
|
||||||
params.Add("artist", escapePlus(artist))
|
params.Add("artist", artist)
|
||||||
params.Add("limit", strconv.Itoa(limit))
|
params.Add("limit", strconv.Itoa(limit))
|
||||||
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
response, err := c.makeRequest(ctx, http.MethodGet, params, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -35,15 +35,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(album.Name).To(Equal("Believe"))
|
Expect(album.Name).To(Equal("Believe"))
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?album=Believe&api_key=API_KEY&artist=U2&format=json&lang=pt&mbid=mbid-1234&method=album.getInfo"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not double-encode plus signs", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.album.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.albumGetInfo(context.Background(), "Lungs", "Florence + the Machine", "", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence + the Machine"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("artistGetInfo", func() {
|
Describe("artistGetInfo", func() {
|
||||||
|
|
@ -57,15 +48,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&lang=pt&method=artist.getInfo"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetInfo(context.Background(), "Florence + the Machine", "en")
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("Florence %2B the Machine"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("fails if Last.fm returns an http status != 200", func() {
|
It("fails if Last.fm returns an http status != 200", func() {
|
||||||
httpClient.Res = http.Response{
|
httpClient.Res = http.Response{
|
||||||
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
Body: io.NopCloser(bytes.NewBufferString(`Internal Server Error`)),
|
||||||
|
|
@ -125,15 +107,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(len(similar.Artists)).To(Equal(2))
|
Expect(len(similar.Artists)).To(Equal(2))
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getSimilar"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetSimilar(context.Background(), "+44", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("%2B44"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("artistGetTopTracks", func() {
|
Describe("artistGetTopTracks", func() {
|
||||||
|
|
@ -146,15 +119,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(len(top.Track)).To(Equal(2))
|
Expect(len(top.Track)).To(Equal(2))
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=U2&format=json&limit=2&method=artist.getTopTracks"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the artist name", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.artist.gettoptracks.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.artistGetTopTracks(context.Background(), "C+C Music Factory", 2)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(httpClient.SavedRequest.URL.Query().Get("artist")).To(Equal("C%2BC Music Factory"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("trackGetSimilar", func() {
|
Describe("trackGetSimilar", func() {
|
||||||
|
|
@ -171,17 +135,6 @@ var _ = Describe("client", func() {
|
||||||
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
Expect(httpClient.SavedRequest.URL.String()).To(Equal(apiBaseUrl + "?api_key=API_KEY&artist=Depeche+Mode&format=json&limit=5&method=track.getSimilar&track=Just+Can%27t+Get+Enough"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("double-encodes plus signs in the track and artist names", func() {
|
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.json")
|
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
|
||||||
|
|
||||||
_, err := client.trackGetSimilar(context.Background(), "1+1", "Queen + Paul Rodgers", 5)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
query := httpClient.SavedRequest.URL.Query()
|
|
||||||
Expect(query.Get("track")).To(Equal("1%2B1"))
|
|
||||||
Expect(query.Get("artist")).To(Equal("Queen %2B Paul Rodgers"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns empty list when no similar tracks found", func() {
|
It("returns empty list when no similar tracks found", func() {
|
||||||
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
f, _ := os.Open("tests/fixtures/lastfm.track.getsimilar.unknown.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package lastfm
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestLastFM(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "LastFM Test Suite")
|
RunSpecs(t, "LastFM Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||||
l := &listenBrainzAgent{
|
l := &listenBrainzAgent{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
baseURL: conf.Server().ListenBrainz.BaseURL,
|
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||||
}
|
}
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
|
|
@ -229,7 +229,7 @@ func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSe
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server().ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
||||||
// See https://go.dev/doc/faq#nil_error
|
// See https://go.dev/doc/faq#nil_error
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
agent = listenBrainzConstructor(ds)
|
agent = listenBrainzConstructor(ds)
|
||||||
agent.client = newClient("http://localhost:8080", httpClient)
|
agent.client = newClient("http://localhost:8080", httpClient)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ func NewRouter(ds model.DataStore) *Router {
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
r.client = newClient(conf.Server().ListenBrainz.BaseURL, hc)
|
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int)
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
||||||
req.URL.RawQuery = url.Values{
|
req.URL.RawQuery = url.Values{
|
||||||
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server().ListenBrainz.ArtistAlgorithm},
|
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
||||||
|
|
@ -354,7 +354,7 @@ func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit in
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
||||||
req.URL.RawQuery = url.Values{
|
req.URL.RawQuery = url.Values{
|
||||||
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server().ListenBrainz.TrackAlgorithm},
|
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -256,6 +257,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
})
|
})
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
getUrl := func(mbid string) string {
|
||||||
|
|
@ -305,7 +307,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
It("fetches a different endpoint when algorithm changes", func() {
|
It("fetches a different endpoint when algorithm changes", func() {
|
||||||
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
||||||
conf.Update(func(c *conf.Options) { c.ListenBrainz.ArtistAlgorithm = algorithm })
|
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
@ -326,6 +328,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
})
|
})
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
getUrl := func(mbid string) string {
|
||||||
|
|
@ -441,7 +444,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
It("uses a different algorithm when configured", func() {
|
It("uses a different algorithm when configured", func() {
|
||||||
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||||
conf.Update(func(c *conf.Options) { c.ListenBrainz.TrackAlgorithm = algorithm })
|
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package listenbrainz
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestListenBrainz(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "ListenBrainz Test Suite")
|
RunSpecs(t, "ListenBrainz Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -174,28 +174,28 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
var rep statusReport
|
var rep statusReport
|
||||||
var err error
|
var err error
|
||||||
if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil {
|
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
|
||||||
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
sources, err := q.SourcesInUse(ctx, k)
|
sources, err := q.SourcesInUse(k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
slices.Sort(sources)
|
slices.Sort(sources)
|
||||||
for _, s := range sources {
|
for _, s := range sources {
|
||||||
n, err := q.CountBySource(ctx, k, []string{s})
|
n, err := q.CountBySource(k, []string{s})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
||||||
}
|
}
|
||||||
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
||||||
// An absent state is exactly a row with no source, so it needs no second query.
|
// An absent state is exactly a row with no source, so it needs no second query.
|
||||||
if s == "" {
|
if s == "" {
|
||||||
failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed})
|
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +205,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
|
||||||
}
|
}
|
||||||
|
|
||||||
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
||||||
if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
||||||
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
return rep, nil
|
return rep, nil
|
||||||
|
|
@ -416,7 +416,7 @@ func needsImageAgents(kinds []model.Kind) bool {
|
||||||
// configuredAgents names the agents in priority order; one absent from it can never supply an image.
|
// configuredAgents names the agents in priority order; one absent from it can never supply an image.
|
||||||
func configuredAgents() []string {
|
func configuredAgents() []string {
|
||||||
var names []string
|
var names []string
|
||||||
for name := range strings.SplitSeq(conf.Server().Agents, ",") {
|
for name := range strings.SplitSeq(conf.Server.Agents, ",") {
|
||||||
if name = strings.TrimSpace(name); name != "" {
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
|
|
@ -442,13 +442,13 @@ func promptConfirm(in io.Reader, verb string) confirmFunc {
|
||||||
|
|
||||||
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
||||||
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
||||||
func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error {
|
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var inUse []string
|
var inUse []string
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
found, err := q.SourcesInUse(ctx, k)
|
found, err := q.SourcesInUse(k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -475,8 +475,8 @@ func validateSources(ctx context.Context, q model.ArtworkQueueRepository, source
|
||||||
// actually inserted; the two differ because an already-queued row is left untouched.
|
// actually inserted; the two differ because an already-queued row is left untouched.
|
||||||
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
||||||
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
if err := validateSources(ctx, q, sources); err != nil {
|
if err := validateSources(q, sources); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
matched := make([]int64, len(kinds))
|
matched := make([]int64, len(kinds))
|
||||||
var total, external int64
|
var total, external int64
|
||||||
for i, k := range kinds {
|
for i, k := range kinds {
|
||||||
n, err := q.CountBySource(ctx, k, sources)
|
n, err := q.CountBySource(k, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting %s artwork: %w", k, err)
|
return fmt.Errorf("counting %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -523,7 +523,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
if matched[i] == 0 {
|
if matched[i] == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck)
|
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -590,8 +590,8 @@ func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T
|
||||||
|
|
||||||
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
||||||
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
matched, err := q.CountQueued(ctx, kinds, priorities)
|
matched, err := q.CountQueued(kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting queued artwork: %w", err)
|
return fmt.Errorf("counting queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +612,7 @@ func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelled, err := q.PurgeQueued(ctx, kinds, priorities)
|
cancelled, err := q.PurgeQueued(kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cancelling queued artwork: %w", err)
|
return fmt.Errorf("cancelling queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -811,13 +811,13 @@ func explainResult(source string, steps []artwork.TraceStep) string {
|
||||||
func explainConfig(kind model.Kind) (name, value string) {
|
func explainConfig(kind model.Kind) (name, value string) {
|
||||||
switch kind {
|
switch kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
return "ArtistArtPriority", conf.Server().ArtistArtPriority
|
return "ArtistArtPriority", conf.Server.ArtistArtPriority
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
return "CoverArtPriority", conf.Server().CoverArtPriority
|
return "CoverArtPriority", conf.Server.CoverArtPriority
|
||||||
case model.KindDiscArtwork:
|
case model.KindDiscArtwork:
|
||||||
return "DiscArtPriority", conf.Server().DiscArtPriority
|
return "DiscArtPriority", conf.Server.DiscArtPriority
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server().EnableMediaFileCoverArt)
|
return "EnableMediaFileCoverArt", strconv.FormatBool(conf.Server.EnableMediaFileCoverArt)
|
||||||
}
|
}
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
|
|
@ -984,11 +984,11 @@ func runExplain(ctx context.Context, args []string) {
|
||||||
}
|
}
|
||||||
rep := explainReport{kind: kind, id: id, name: name}
|
rep := explainReport{kind: kind, id: id, name: name}
|
||||||
if artwork.KeepsState(kind) {
|
if artwork.KeepsState(kind) {
|
||||||
rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary)
|
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
|
|
@ -1002,7 +1002,7 @@ func runExplain(ctx context.Context, args []string) {
|
||||||
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
|
if kind == model.KindArtistArtwork || kind == model.KindAlbumArtwork {
|
||||||
mgr := loadPluginAgents(ctx, explainLive)
|
mgr := loadPluginAgents(ctx, explainLive)
|
||||||
defer func() { _ = mgr.Stop() }()
|
defer func() { _ = mgr.Stop() }()
|
||||||
rep.agents = explainAgents(conf.Server().Agents, availableImageAgents(ds, mgr, kind))
|
rep.agents = explainAgents(conf.Server.Agents, availableImageAgents(ds, mgr, kind))
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case rep.walked:
|
case rep.walked:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -225,7 +226,8 @@ var _ = Describe("formatExplain", func() {
|
||||||
var rep explainReport
|
var rep explainReport
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "external, artist.*" })
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.ArtistArtPriority = "external, artist.*"
|
||||||
rep = explainReport{
|
rep = explainReport{
|
||||||
kind: model.KindArtistArtwork,
|
kind: model.KindArtistArtwork,
|
||||||
id: "ar-1",
|
id: "ar-1",
|
||||||
|
|
@ -287,7 +289,7 @@ var _ = Describe("formatExplain", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("says a kind that does not walk a chain has no chain, without an empty table", func() {
|
It("says a kind that does not walk a chain has no chain, without an empty table", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*, embedded" })
|
conf.Server.CoverArtPriority = "cover.*, embedded"
|
||||||
rep.kind = model.KindPlaylistArtwork
|
rep.kind = model.KindPlaylistArtwork
|
||||||
rep.steps = nil
|
rep.steps = nil
|
||||||
rep.agents = ""
|
rep.agents = ""
|
||||||
|
|
@ -303,7 +305,7 @@ var _ = Describe("formatExplain", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
|
It("says disc artwork keeps no state instead of reporting it as unresolved state", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "cover.jpg, embedded" })
|
conf.Server.DiscArtPriority = "cover.jpg, embedded"
|
||||||
rep = explainReport{
|
rep = explainReport{
|
||||||
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
|
kind: model.KindDiscArtwork, id: "al-1:2", name: "OK Computer (disc 2)",
|
||||||
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
|
steps: []artwork.TraceStep{{Candidate: "cover.jpg", Outcome: "hit", Detail: "/music/cover.jpg"}},
|
||||||
|
|
@ -385,7 +387,7 @@ var _ = Describe("formatExplain", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports the setting that governs media file artwork", func() {
|
It("reports the setting that governs media file artwork", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = false })
|
conf.Server.EnableMediaFileCoverArt = false
|
||||||
rep = explainReport{
|
rep = explainReport{
|
||||||
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
|
kind: model.KindMediaFileArtwork, id: "mf-1", name: "Airbag",
|
||||||
walked: true,
|
walked: true,
|
||||||
|
|
@ -404,10 +406,9 @@ var _ = Describe("formatExplain", func() {
|
||||||
|
|
||||||
var _ = Describe("explainConfig", func() {
|
var _ = Describe("explainConfig", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
DeferCleanup(configtest.SetupConfig())
|
||||||
c.DiscArtPriority = "cover.jpg"
|
conf.Server.DiscArtPriority = "cover.jpg"
|
||||||
c.EnableMediaFileCoverArt = true
|
conf.Server.EnableMediaFileCoverArt = true
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("names the setting that decides where a kind's artwork comes from",
|
DescribeTable("names the setting that decides where a kind's artwork comes from",
|
||||||
|
|
@ -490,17 +491,17 @@ var _ = Describe("explain/reprocess source round trip", func() {
|
||||||
|
|
||||||
It("names the absent state as reprocess --source accepts it", func() {
|
It("names the absent state as reprocess --source accepts it", func() {
|
||||||
ds := &tests.MockDataStore{}
|
ds := &tests.MockDataStore{}
|
||||||
art := ds.Artwork().(*tests.MockArtworkRepo)
|
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||||
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
|
|
||||||
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
||||||
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
||||||
|
|
||||||
q := ds.ArtworkQueue()
|
q := ds.ArtworkQueue(ctx)
|
||||||
Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(),
|
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
||||||
"explain's spelling of a source must be pasteable into --source")
|
"explain's spelling of a source must be pasteable into --source")
|
||||||
Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
||||||
"a parenthesised name would be rejected, so explain must not print one")
|
"a parenthesised name would be rejected, so explain must not print one")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -572,20 +573,19 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
decline := func(io.Writer, int64, int64) bool { return false }
|
decline := func(io.Writer, int64, int64) bool { return false }
|
||||||
|
|
||||||
put := func(kind model.Kind, id, source string) {
|
put := func(kind model.Kind, id, source string) {
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
DeferCleanup(configtest.SetupConfig())
|
||||||
c.CoverArtPriority = "cover.*, external"
|
conf.Server.CoverArtPriority = "cover.*, external"
|
||||||
c.ArtistArtPriority = "artist.*, external"
|
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||||
c.EnableM3UExternalAlbumArt = false
|
conf.Server.EnableM3UExternalAlbumArt = false
|
||||||
})
|
|
||||||
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
imageAgents = artwork.ImageAgentCount{Artist: 1, Album: 1}
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
||||||
put(model.KindArtistArtwork, "ar-2", "")
|
put(model.KindArtistArtwork, "ar-2", "")
|
||||||
|
|
@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("album"))
|
Expect(out.String()).To(ContainSubstring("album"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("queues nothing when the operator declines", func() {
|
It("queues nothing when the operator declines", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
||||||
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
||||||
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
if applied {
|
if applied {
|
||||||
want = artwork.ConfigFingerprint()
|
want = artwork.ConfigFingerprint()
|
||||||
}
|
}
|
||||||
Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
||||||
},
|
},
|
||||||
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
||||||
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
||||||
|
|
@ -633,14 +633,14 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count(ctx)).To(Equal(int64(2)))
|
Expect(queue.Count()).To(Equal(int64(2)))
|
||||||
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||||
_, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
||||||
|
|
||||||
stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
||||||
})
|
})
|
||||||
|
|
@ -648,20 +648,20 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("targets the absent state", func() {
|
It("targets the absent state", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count(ctx)).To(Equal(int64(1)))
|
Expect(queue.Count()).To(Equal(int64(1)))
|
||||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports matched and queued separately when part of the set is already queued", func() {
|
It("reports matched and queued separately when part of the set is already queued", func() {
|
||||||
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
||||||
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
||||||
queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
||||||
"an already-queued row keeps its priority and backoff")
|
"an already-queued row keeps its priority and backoff")
|
||||||
|
|
@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
}, &out)).To(Succeed())
|
}, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing"))
|
Expect(out.String()).To(ContainSubstring("Nothing"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports an empty selection as a dry run when one was asked for", func() {
|
It("reports an empty selection as a dry run when one was asked for", func() {
|
||||||
|
|
@ -714,7 +714,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("says so when the selection needs no external lookup", func() {
|
It("says so when the selection needs no external lookup", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
put(model.KindPlaylistArtwork, "pl-1", "playlist")
|
put(model.KindPlaylistArtwork, "pl-1", "playlist")
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, imageAgents, true, accept, &out)).To(Succeed())
|
||||||
|
|
@ -723,10 +723,8 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("counts playlists as external cost when the m3u image fetch is enabled", func() {
|
It("counts playlists as external cost when the m3u image fetch is enabled", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
c.CoverArtPriority = "cover.*"
|
conf.Server.EnableM3UExternalAlbumArt = true
|
||||||
c.EnableM3UExternalAlbumArt = true
|
|
||||||
})
|
|
||||||
put(model.KindPlaylistArtwork, "pl-1", "playlist")
|
put(model.KindPlaylistArtwork, "pl-1", "playlist")
|
||||||
var external int64
|
var external int64
|
||||||
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
|
capture := func(_ io.Writer, _, e int64) bool { external = e; return false }
|
||||||
|
|
@ -769,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
||||||
Expect(err.Error()).To(ContainSubstring("folder"))
|
Expect(err.Error()).To(ContainSubstring("folder"))
|
||||||
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
||||||
|
|
@ -779,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
imageAgents, false, accept, &out)).To(Succeed(),
|
imageAgents, false, accept, &out)).To(Succeed(),
|
||||||
"a reserved source must stay valid once the library has none of it")
|
"a reserved source must stay valid once the library has none of it")
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
||||||
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
||||||
|
|
@ -799,7 +797,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
||||||
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -818,19 +816,19 @@ var _ = Describe("collectStatus", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
||||||
}
|
}
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
||||||
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
||||||
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
||||||
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
||||||
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
||||||
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -850,7 +848,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("compares the stored fingerprint against the current one", func() {
|
It("compares the stored fingerprint against the current one", func() {
|
||||||
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
rep, err := collectStatus(ctx, ds)
|
rep, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -862,7 +860,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
It("queues nothing", func() {
|
It("queues nothing", func() {
|
||||||
_, err := collectStatus(ctx, ds)
|
_, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything")
|
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -975,21 +973,21 @@ var _ = Describe("refreshItems", func() {
|
||||||
albums := tests.CreateMockAlbumRepo()
|
albums := tests.CreateMockAlbumRepo()
|
||||||
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albums}
|
ds = &tests.MockDataStore{MockedAlbum: albums}
|
||||||
art = ds.Artwork().(*tests.MockArtworkRepo)
|
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears the stored state and queues each id at Bump priority", func() {
|
It("clears the stored state and queues each id at Bump priority", func() {
|
||||||
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
||||||
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
||||||
|
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
||||||
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
||||||
|
|
||||||
_, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
||||||
|
|
@ -998,7 +996,7 @@ var _ = Describe("refreshItems", func() {
|
||||||
It("skips an id that does not exist instead of queuing it", func() {
|
It("skips an id that does not exist instead of queuing it", func() {
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
||||||
|
|
||||||
_, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
||||||
Expect(out.String()).To(BeEmpty())
|
Expect(out.String()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -1014,11 +1012,10 @@ var _ = Describe("refreshItems", func() {
|
||||||
|
|
||||||
var _ = Describe("needsImageAgents", func() {
|
var _ = Describe("needsImageAgents", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
DeferCleanup(configtest.SetupConfig())
|
||||||
c.CoverArtPriority = "cover.*, external"
|
conf.Server.CoverArtPriority = "cover.*, external"
|
||||||
c.ArtistArtPriority = "artist.*, external"
|
conf.Server.ArtistArtPriority = "artist.*, external"
|
||||||
c.EnableM3UExternalAlbumArt = false
|
conf.Server.EnableM3UExternalAlbumArt = false
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is false for a selection no agent can serve", func() {
|
It("is false for a selection no agent can serve", func() {
|
||||||
|
|
@ -1036,25 +1033,24 @@ var _ = Describe("needsImageAgents", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is false once the chains no longer reach an agent", func() {
|
It("is false once the chains no longer reach an agent", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
c.CoverArtPriority = "cover.*"
|
conf.Server.ArtistArtPriority = "artist.*"
|
||||||
c.ArtistArtPriority = "artist.*"
|
|
||||||
})
|
|
||||||
Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse())
|
Expect(needsImageAgents(artwork.ReprocessKinds)).To(BeFalse())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("configuredAgents", func() {
|
var _ = Describe("configuredAgents", func() {
|
||||||
|
BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) })
|
||||||
|
|
||||||
It("splits and trims the configured list", func() {
|
It("splits and trims the configured list", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "lastfm, spotify ,deezer" })
|
conf.Server.Agents = "lastfm, spotify ,deezer"
|
||||||
Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"}))
|
Expect(configuredAgents()).To(Equal([]string{"lastfm", "spotify", "deezer"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
// An empty name would match no plugin, but it also must not make the list look non-empty:
|
// An empty name would match no plugin, but it also must not make the list look non-empty:
|
||||||
// LoadPlugins treats an empty list as "load nothing".
|
// LoadPlugins treats an empty list as "load nothing".
|
||||||
It("drops empty entries rather than passing a name nothing can match", func() {
|
It("drops empty entries rather than passing a name nothing can match", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = " , ," })
|
conf.Server.Agents = " , ,"
|
||||||
Expect(configuredAgents()).To(BeEmpty())
|
Expect(configuredAgents()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1149,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
Expect(queue.Enqueue(ctx,
|
Expect(queue.Enqueue(
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBackfill},
|
Priority: model.ArtworkPriorityBackfill},
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
||||||
|
|
@ -1168,28 +1164,28 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("backfill"))
|
Expect(out.String()).To(ContainSubstring("backfill"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels nothing when the operator declines", func() {
|
It("cancels nothing when the operator declines", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("deletes the selected rows and leaves the rest queued", func() {
|
It("deletes the selected rows and leaves the rest queued", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 1))
|
Expect(queue.Count()).To(BeNumerically("==", 1))
|
||||||
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
||||||
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels every kind and priority when neither filter is given", func() {
|
It("cancels every kind and priority when neither filter is given", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
||||||
Expect(queue.Count(ctx)).To(BeZero())
|
Expect(queue.Count()).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("stops at a selection that matches nothing instead of prompting", func() {
|
It("stops at a selection that matches nothing instead of prompting", func() {
|
||||||
|
|
@ -1200,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
||||||
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
Expect(queue.Count()).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -15,7 +17,6 @@ var (
|
||||||
backupCount int
|
backupCount int
|
||||||
backupDir string
|
backupDir string
|
||||||
force bool
|
force bool
|
||||||
integrityCheck bool
|
|
||||||
restorePath string
|
restorePath string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -30,9 +31,8 @@ func init() {
|
||||||
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
pruneCmd.Flags().BoolVarP(&force, "force", "f", false, "bypass warning when backup count is zero")
|
||||||
backupRoot.AddCommand(pruneCmd)
|
backupRoot.AddCommand(pruneCmd)
|
||||||
|
|
||||||
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
|
restoreCommand.Flags().StringVarP(&restorePath, "backup-file", "b", "", "path of backup database to restore")
|
||||||
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
restoreCommand.Flags().BoolVarP(&force, "force", "f", false, "bypass restore warning")
|
||||||
restoreCommand.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")
|
_ = restoreCommand.MarkFlagRequired("backup-file")
|
||||||
backupRoot.AddCommand(restoreCommand)
|
backupRoot.AddCommand(restoreCommand)
|
||||||
}
|
}
|
||||||
|
|
@ -75,15 +75,27 @@ var (
|
||||||
|
|
||||||
func runBackup(ctx context.Context) {
|
func runBackup(ctx context.Context) {
|
||||||
if backupDir != "" {
|
if backupDir != "" {
|
||||||
conf.Update(func(c *conf.Options) { c.Backup.Path = conf.NewDir(backupDir) })
|
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()
|
start := time.Now()
|
||||||
path, err := db.Backup(ctx)
|
path, err := db.Backup(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error backing up database", "backupPath", conf.Server().Backup.Path, err)
|
log.Fatal("Error backing up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -91,26 +103,44 @@ func runBackup(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runPrune(ctx context.Context) {
|
func runPrune(ctx context.Context) {
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
if backupDir != "" {
|
if backupDir != "" {
|
||||||
c.Backup.Path = conf.NewDir(backupDir)
|
conf.Server.Backup.Path = conf.NewDir(backupDir)
|
||||||
}
|
}
|
||||||
if backupCount != -1 {
|
|
||||||
c.Backup.Count = backupCount
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if conf.Server().Backup.Count == 0 && !force && !confirmYES("Warning: pruning ALL backups") {
|
if backupCount != -1 {
|
||||||
|
conf.Server.Backup.Count = backupCount
|
||||||
|
}
|
||||||
|
|
||||||
|
if conf.Server.Backup.Count == 0 && !force {
|
||||||
|
fmt.Println("Warning: pruning ALL backups")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
log.Warn("Prune cancelled")
|
log.Warn("Prune cancelled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
|
if idx == -1 {
|
||||||
|
path = conf.Server.DbPath
|
||||||
|
} else {
|
||||||
|
path = conf.Server.DbPath[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
count, err := db.Prune(ctx)
|
count, err := db.Prune(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error pruning database", "backupPath", conf.Server().Backup.Path, err)
|
log.Fatal("Error pruning up database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
@ -119,29 +149,36 @@ func runPrune(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func runRestore(ctx context.Context) {
|
func runRestore(ctx context.Context) {
|
||||||
requireExistingDB()
|
idx := strings.LastIndex(conf.Server.DbPath, "?")
|
||||||
|
var path string
|
||||||
|
|
||||||
// A relative --backup-file is resolved against Backup.Path, the same folder
|
if idx == -1 {
|
||||||
// `backup create` writes to. Without this, the value was treated as relative
|
path = conf.Server.DbPath
|
||||||
// to the working directory, where the file does not exist.
|
} else {
|
||||||
if !filepath.IsAbs(restorePath) {
|
path = conf.Server.DbPath[:idx]
|
||||||
backupPath, err := conf.Server().Backup.Path.Path()
|
}
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Backup directory not available", "backupPath", conf.Server().Backup.Path, err)
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
log.Fatal("No existing database", "path", path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
restorePath = filepath.Join(backupPath, restorePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !force && !confirmYES("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.") {
|
if !force {
|
||||||
|
fmt.Println("Warning: restoring the Navidrome database should only be done offline, especially if your backup is very old.")
|
||||||
|
fmt.Printf("Please enter YES (all caps) to continue: ")
|
||||||
|
var input string
|
||||||
|
_, err := fmt.Scanln(&input)
|
||||||
|
|
||||||
|
if input != "YES" || err != nil {
|
||||||
log.Warn("Restore cancelled")
|
log.Warn("Restore cancelled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := db.Restore(ctx, restorePath, integrityCheck)
|
err := db.Restore(ctx, restorePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
log.Fatal("Error restoring database", "backup path", conf.Server.BasePath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start)
|
elapsed := time.Since(start)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestCmd(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Cmd Suite")
|
RunSpecs(t, "Cmd Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
rootCmd.AddCommand(doctorCmd)
|
|
||||||
}
|
|
||||||
|
|
||||||
var doctorCmd = &cobra.Command{
|
|
||||||
Use: "doctor",
|
|
||||||
Short: "Check your Navidrome installation for problems",
|
|
||||||
Long: "Run read-only health checks and report what was found. Checks the database for " +
|
|
||||||
"corruption and foreign key violations, and reports whether 'navidrome search rebuild' " +
|
|
||||||
"can fix what it finds. This command never alters your data",
|
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
|
||||||
runDoctor(cmd.Context())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
func runDoctor(ctx context.Context) {
|
|
||||||
requireExistingDB()
|
|
||||||
|
|
||||||
healthy := doctor(ctx, db.Db(), os.Stdout)
|
|
||||||
db.Close(ctx)
|
|
||||||
if !healthy {
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recoveryAdvice = "Restore a backup (navidrome backup restore), or try SQLite's '.recover' command."
|
|
||||||
|
|
||||||
func printFindings(out io.Writer, check, noun string, items []string) {
|
|
||||||
fmt.Fprintf(out, "%s reported %d %s:\n", check, len(items), noun)
|
|
||||||
for _, item := range items {
|
|
||||||
fmt.Fprintln(out, " "+item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func doctor(ctx context.Context, database *sql.DB, out io.Writer) bool {
|
|
||||||
healthy := true
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking database integrity...")
|
|
||||||
issues, truncated, err := db.IntegrityCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
fmt.Fprintln(out, "The integrity check could not complete: "+err.Error())
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
return false
|
|
||||||
case len(issues) == 0:
|
|
||||||
fmt.Fprintln(out, "Integrity check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
printFindings(out, "Integrity check", "issue(s)", issues)
|
|
||||||
switch {
|
|
||||||
case truncated:
|
|
||||||
fmt.Fprintln(out, "The integrity check stopped at its limit, so the damage may reach further than listed.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
case db.IsFTSCorruptionOnly(issues):
|
|
||||||
fmt.Fprintln(out, "Corruption is limited to the search index. Run 'navidrome search rebuild' to fix it.")
|
|
||||||
default:
|
|
||||||
fmt.Fprintln(out, "Corruption is not limited to the search index, and cannot be repaired automatically.")
|
|
||||||
fmt.Fprintln(out, recoveryAdvice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Fprintln(out, "Checking foreign keys...")
|
|
||||||
violations, err := db.ForeignKeyCheck(ctx, database)
|
|
||||||
switch {
|
|
||||||
case err != nil:
|
|
||||||
healthy = false
|
|
||||||
fmt.Fprintln(out, "The foreign key check could not complete: "+err.Error())
|
|
||||||
case len(violations) == 0:
|
|
||||||
fmt.Fprintln(out, "Foreign key check passed.")
|
|
||||||
default:
|
|
||||||
healthy = false
|
|
||||||
lines := make([]string, 0, len(violations))
|
|
||||||
for _, v := range violations {
|
|
||||||
lines = append(lines,
|
|
||||||
fmt.Sprintf("%s: %d row(s) reference missing rows in %s", v.Table, v.Count, v.Parent))
|
|
||||||
}
|
|
||||||
printFindings(out, "Foreign key check", "violation(s)", lines)
|
|
||||||
fmt.Fprintln(out, "These are orphaned rows, not corruption. 'navidrome scan -f' clears some of them "+
|
|
||||||
"in library data; the rest have to be removed by hand.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if healthy {
|
|
||||||
fmt.Fprintln(out, "Database is healthy.")
|
|
||||||
}
|
|
||||||
return healthy
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"database/sql"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("doctor", func() {
|
|
||||||
var (
|
|
||||||
ctx context.Context
|
|
||||||
dbPath string
|
|
||||||
database *sql.DB
|
|
||||||
out *strings.Builder
|
|
||||||
reopen func()
|
|
||||||
)
|
|
||||||
|
|
||||||
// A file-backed DB so specs can corrupt raw pages; a table named like a real FTS
|
|
||||||
// search table so IsFTSCorruptionOnly matches, plus a parent/child pair for FK checks.
|
|
||||||
BeforeEach(func() {
|
|
||||||
ctx = context.Background()
|
|
||||||
dbPath = filepath.Join(GinkgoT().TempDir(), "doctor.db")
|
|
||||||
reopen = func() {
|
|
||||||
var err error
|
|
||||||
database, err = sql.Open(db.Dialect, dbPath)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
database.SetMaxOpenConns(1)
|
|
||||||
}
|
|
||||||
reopen()
|
|
||||||
DeferCleanup(func() { _ = database.Close() })
|
|
||||||
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create virtual table media_file_fts using fts5(title, content='', content_rowid='rowid')`,
|
|
||||||
`insert into media_file_fts(rowid, title) values (1, 'teenage lobotomy'), (2, 'rockaway beach')`,
|
|
||||||
`create table library(id integer primary key)`,
|
|
||||||
`create table media_file(id integer primary key, library_id integer references library(id))`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
out = &strings.Builder{}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports a healthy database", func() {
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeTrue())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Database is healthy."))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to 'search rebuild' when corruption is limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`update media_file_fts_data set block = x'deadbeefdeadbeef' where id > 1`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("points to a backup restore when corruption is not limited to the search index", func() {
|
|
||||||
_, err := database.ExecContext(ctx,
|
|
||||||
`insert into library(id)
|
|
||||||
with recursive s(x) as (select 1 union all select x+1 from s where x < 200)
|
|
||||||
select x from s`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
var rootPage, pageSize int64
|
|
||||||
Expect(database.QueryRowContext(ctx,
|
|
||||||
`select rootpage from sqlite_master where name = 'library'`).Scan(&rootPage)).To(Succeed())
|
|
||||||
Expect(database.QueryRowContext(ctx, `pragma page_size`).Scan(&pageSize)).To(Succeed())
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
f, err := os.OpenFile(dbPath, os.O_WRONLY, 0600)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = f.WriteAt([]byte{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef}, (rootPage-1)*pageSize+40)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(f.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("reports foreign key violations", func() {
|
|
||||||
_, err := database.ExecContext(ctx, `pragma foreign_keys = off`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_, err = database.ExecContext(ctx, `insert into media_file(id, library_id) values (1, 999)`)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).To(ContainSubstring("Foreign key check reported"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("media_file"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("navidrome scan -f"))
|
|
||||||
// GC never touches player, share or playqueue, so don't promise a full cleanup.
|
|
||||||
Expect(out.String()).To(ContainSubstring("removed by hand"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Every issue names an FTS-like index, so IsFTSCorruptionOnly alone would send the
|
|
||||||
// user to 'search rebuild', but the pragma stopped at its limit without saying so.
|
|
||||||
It("does not blame the search index when the issue list is truncated", func() {
|
|
||||||
for _, stmt := range []string{
|
|
||||||
`create table t(a, b)`,
|
|
||||||
`with recursive s(x) as (select 1 union all select x+1 from s where x < 300)
|
|
||||||
insert into t select x, x + 10000 from s`,
|
|
||||||
`create index media_file_fts_probe on t(a)`,
|
|
||||||
`pragma writable_schema=on`,
|
|
||||||
`update sqlite_master set sql = 'CREATE INDEX media_file_fts_probe ON t(b)'
|
|
||||||
where name = 'media_file_fts_probe'`,
|
|
||||||
} {
|
|
||||||
_, err := database.ExecContext(ctx, stmt)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
}
|
|
||||||
Expect(database.Close()).To(Succeed())
|
|
||||||
reopen()
|
|
||||||
|
|
||||||
Expect(doctor(ctx, database, out)).To(BeFalse())
|
|
||||||
Expect(out.String()).ToNot(ContainSubstring("search rebuild"))
|
|
||||||
Expect(out.String()).To(ContainSubstring("backup restore"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -32,7 +28,7 @@ var inspectCmd = &cobra.Command{
|
||||||
Long: "Show file tags as seen by Navidrome",
|
Long: "Show file tags as seen by Navidrome",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runInspector(cmd.Context(), args)
|
runInspector(args)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
|
||||||
return []byte(res.String()), nil
|
return []byte(res.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspector(ctx context.Context, args []string) {
|
func runInspector(args []string) {
|
||||||
marshal := marshalers[format]
|
marshal := marshalers[format]
|
||||||
if marshal == nil {
|
if marshal == nil {
|
||||||
log.Fatal("Invalid format", "format", format)
|
log.Fatal("Invalid format", "format", format)
|
||||||
}
|
}
|
||||||
libs := loadLibraries(ctx)
|
|
||||||
matcher := model.NewLibraryMatcher(libs)
|
|
||||||
var out []core.InspectOutput
|
var out []core.InspectOutput
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
if !model.IsAudioFile(filePath) {
|
if !model.IsAudioFile(filePath) {
|
||||||
log.Warn("Not an audio file", "file", filePath)
|
log.Warn("Not an audio file", "file", filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lib, ok := libraryForFile(matcher, filePath)
|
output, err := core.Inspect(filePath, 1, "")
|
||||||
if !ok && len(libs) > 0 {
|
|
||||||
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
|
||||||
}
|
|
||||||
output, err := core.Inspect(filePath, lib, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
|
||||||
data, _ := marshal(out)
|
data, _ := marshal(out)
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
|
|
||||||
func loadLibraries(ctx context.Context) model.Libraries {
|
|
||||||
if dbFile, ok := existingDBFile(); !ok {
|
|
||||||
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer db.Init(ctx)()
|
|
||||||
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range libs {
|
|
||||||
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
|
|
||||||
libs[i].Path = absPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return libs
|
|
||||||
}
|
|
||||||
|
|
||||||
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
|
|
||||||
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
|
|
||||||
if absPath, err := filepath.Abs(filePath); err == nil {
|
|
||||||
if lib, ok := matcher.FindLibrary(absPath); ok {
|
|
||||||
return lib, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.Library{ID: model.DefaultLibraryID}, false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"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() {
|
|
||||||
|
|
||||||
It("does not create a database when there is none", func() {
|
|
||||||
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
|
|
||||||
conf.Update(func(c *conf.Options) { c.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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
10
cmd/pls.go
10
cmd/pls.go
|
|
@ -109,7 +109,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
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 {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||||
}
|
}
|
||||||
|
|
@ -117,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
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) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if len(playlists) > 0 {
|
if len(playlists) > 0 {
|
||||||
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +194,7 @@ func runExport(ctx context.Context) {
|
||||||
|
|
||||||
exported := 0
|
exported := 0
|
||||||
for _, pls := range allPls {
|
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 {
|
if err != nil {
|
||||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin().Get(ctx, arg)
|
p, err := ds.Plugin(ctx).Get(arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin().Get(ctx, arg)
|
p, err := ds.Plugin(ctx).Get(arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -329,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||||
func runPluginList(ctx context.Context) {
|
func runPluginList(ctx context.Context) {
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
list, err := ds.Plugin().GetAll(ctx)
|
list, err := ds.Plugin(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to list plugins", err)
|
log.Fatal(ctx, "Failed to list plugins", err)
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +343,7 @@ func runPluginList(ctx context.Context) {
|
||||||
// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp
|
// requirePluginsEnabled gates DB/manager-backed commands; off-disk .ndp
|
||||||
// inspection deliberately skips this so it works without a configured server.
|
// inspection deliberately skips this so it works without a configured server.
|
||||||
func requirePluginsEnabled(ctx context.Context) {
|
func requirePluginsEnabled(ctx context.Context) {
|
||||||
if !conf.Server().Plugins.Enabled {
|
if !conf.Server.Plugins.Enabled {
|
||||||
log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)")
|
log.Fatal(ctx, "Plugin system is disabled (set Plugins.Enabled to use this command)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
requirePluginsEnabled(cmd.Context())
|
requirePluginsEnabled(cmd.Context())
|
||||||
ds, ctx := getAdminContext(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 {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
138
cmd/root.go
138
cmd/root.go
|
|
@ -44,9 +44,7 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||||
preRun()
|
preRun()
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if err := runNavidrome(cmd.Context()); err != nil {
|
runNavidrome(cmd.Context())
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
PostRun: func(cmd *cobra.Command, args []string) {
|
PostRun: func(cmd *cobra.Command, args []string) {
|
||||||
postRun()
|
postRun()
|
||||||
|
|
@ -78,17 +76,16 @@ func postRun() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||||
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
||||||
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
// it will cancel the context and exit gracefully.
|
||||||
func runNavidrome(parentCtx context.Context) error {
|
func runNavidrome(ctx context.Context) {
|
||||||
defer db.Init(parentCtx)()
|
defer db.Init(ctx)()
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(parentCtx)
|
g, ctx := errgroup.WithContext(ctx)
|
||||||
g.Go(startServer(ctx))
|
g.Go(startServer(ctx))
|
||||||
g.Go(startSignaller(ctx))
|
g.Go(startSignaller(ctx))
|
||||||
g.Go(startScheduler(ctx))
|
g.Go(startScheduler(ctx))
|
||||||
g.Go(startPlaybackServer(ctx))
|
g.Go(startPlaybackServer(ctx))
|
||||||
g.Go(startJellyfinDiscovery(ctx))
|
|
||||||
g.Go(schedulePeriodicBackup(ctx))
|
g.Go(schedulePeriodicBackup(ctx))
|
||||||
g.Go(startInsightsCollector(ctx))
|
g.Go(startInsightsCollector(ctx))
|
||||||
g.Go(scheduleDBAnalyzer(ctx))
|
g.Go(scheduleDBAnalyzer(ctx))
|
||||||
|
|
@ -97,18 +94,16 @@ func runNavidrome(parentCtx context.Context) error {
|
||||||
g.Go(startArtworkWorker(ctx, artworkWorker))
|
g.Go(startArtworkWorker(ctx, artworkWorker))
|
||||||
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
|
g.Go(scheduleArtworkHousekeeping(ctx, artworkWorker))
|
||||||
g.Go(runInitialScan(ctx))
|
g.Go(runInitialScan(ctx))
|
||||||
if conf.Server().Scanner.Enabled {
|
if conf.Server.Scanner.Enabled {
|
||||||
g.Go(startScanWatcher(ctx))
|
g.Go(startScanWatcher(ctx))
|
||||||
g.Go(schedulePeriodicScan(ctx))
|
g.Go(schedulePeriodicScan(ctx))
|
||||||
} else {
|
} else {
|
||||||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errors caused by a normal shutdown are not failures
|
if err := g.Wait(); err != nil {
|
||||||
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
log.Error("Fatal error in Navidrome. Aborting", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||||
|
|
@ -128,31 +123,28 @@ func startServer(ctx context.Context) func() error {
|
||||||
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx))
|
a.MountRouter("Native API", consts.URLPathNativeAPI, CreateNativeAPIRouter(ctx))
|
||||||
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx))
|
a.MountRouter("Subsonic API", consts.URLPathSubsonicAPI, CreateSubsonicAPIRouter(ctx))
|
||||||
a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
|
a.MountRouter("Public Endpoints", consts.URLPathPublic, CreatePublicRouter())
|
||||||
if conf.Server().LastFM.Enabled {
|
if conf.Server.LastFM.Enabled {
|
||||||
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
|
a.MountRouter("LastFM Auth", consts.URLPathNativeAPI+"/lastfm", CreateLastFMRouter())
|
||||||
}
|
}
|
||||||
if conf.Server().ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
a.MountRouter("ListenBrainz Auth", consts.URLPathNativeAPI+"/listenbrainz", CreateListenBrainzRouter())
|
||||||
}
|
}
|
||||||
if conf.Server().Jellyfin.Enabled {
|
if conf.Server.Jellyfin.Enabled {
|
||||||
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
a.MountRouter("Jellyfin API", consts.URLPathJellyfinAPI, CreateJellyfinAPIRouter(ctx))
|
||||||
}
|
}
|
||||||
if conf.Server().DevAPIv1 {
|
if conf.Server.Prometheus.Enabled {
|
||||||
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
|
|
||||||
}
|
|
||||||
if conf.Server().Prometheus.Enabled {
|
|
||||||
p := CreatePrometheus()
|
p := CreatePrometheus()
|
||||||
// blocking call because takes <100ms but useful if fails
|
// blocking call because takes <100ms but useful if fails
|
||||||
p.WriteInitialMetrics(ctx)
|
p.WriteInitialMetrics(ctx)
|
||||||
a.MountRouter("Prometheus metrics", conf.Server().Prometheus.MetricsPath, p.GetHandler())
|
a.MountRouter("Prometheus metrics", conf.Server.Prometheus.MetricsPath, p.GetHandler())
|
||||||
}
|
}
|
||||||
if conf.Server().DevEnableProfiler {
|
if conf.Server.DevEnableProfiler {
|
||||||
a.MountRouter("Profiling", "/debug", profilerHandler())
|
a.MountRouter("Profiling", "/debug", profilerHandler())
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(conf.Server().UILoginBackgroundURL, "/") {
|
if strings.HasPrefix(conf.Server.UILoginBackgroundURL, "/") {
|
||||||
a.MountRouter("Background images", conf.Server().UILoginBackgroundURL, backgrounds.NewHandler())
|
a.MountRouter("Background images", conf.Server.UILoginBackgroundURL, backgrounds.NewHandler())
|
||||||
}
|
}
|
||||||
return a.Run(ctx, conf.Server().Address, conf.Server().Port, conf.Server().TLSCert, conf.Server().TLSKey)
|
return a.Run(ctx, conf.Server.Address, conf.Server.Port, conf.Server.TLSCert, conf.Server.TLSKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,14 +152,14 @@ func startServer(ctx context.Context) func() error {
|
||||||
// name from the raw request path, so the BasePath has to come off first.
|
// name from the raw request path, so the BasePath has to come off first.
|
||||||
func profilerHandler() http.Handler {
|
func profilerHandler() http.Handler {
|
||||||
// A trailing or root slash would make StripPrefix drop the leading slash chi needs.
|
// A trailing or root slash would make StripPrefix drop the leading slash chi needs.
|
||||||
basePath := strings.TrimRight(conf.Server().BasePath, "/")
|
basePath := strings.TrimRight(conf.Server.BasePath, "/")
|
||||||
return http.StripPrefix(basePath, middleware.Profiler())
|
return http.StripPrefix(basePath, middleware.Profiler())
|
||||||
}
|
}
|
||||||
|
|
||||||
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
// schedulePeriodicScan schedules a periodic scan of the music library, if configured.
|
||||||
func schedulePeriodicScan(ctx context.Context) func() error {
|
func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
schedule := conf.Server().Scanner.Schedule
|
schedule := conf.Server.Scanner.Schedule
|
||||||
if schedule == "" {
|
if schedule == "" {
|
||||||
log.Info(ctx, "Periodic scan is DISABLED")
|
log.Info(ctx, "Periodic scan is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -190,50 +182,46 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
func pidHashChanged(ds model.DataStore) (bool, error) {
|
||||||
// the one used by their last finished scan
|
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
||||||
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
|
||||||
libs, err := ds.Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return false, err
|
||||||
}
|
}
|
||||||
var names []string
|
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
||||||
for _, lib := range libs {
|
if err != nil {
|
||||||
if lib.PIDChanged() {
|
return false, err
|
||||||
names = append(names, lib.Name)
|
|
||||||
}
|
}
|
||||||
}
|
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
||||||
return names, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInitialScan runs an initial scan of the music library if needed.
|
// runInitialScan runs an initial scan of the music library if needed.
|
||||||
func runInitialScan(ctx context.Context) func() error {
|
func runInitialScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
ds := CreateDataStore()
|
ds := CreateDataStore()
|
||||||
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inProgress, err := ds.Library().ScanInProgress(ctx)
|
inProgress, err := ds.Library(ctx).ScanInProgress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pidChangedLibs, err := librariesWithChangedPID(ctx, ds)
|
pidHasChanged, err := pidHashChanged(ds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
scanOnStartup := conf.Server().Scanner.Enabled && conf.Server().Scanner.ScanOnStartup
|
scanOnStartup := conf.Server.Scanner.Enabled && conf.Server.Scanner.ScanOnStartup
|
||||||
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || pidHasChanged
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
switch {
|
switch {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
||||||
case len(pidChangedLibs) > 0:
|
case pidHasChanged:
|
||||||
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
log.Warn(ctx, "PID config changed, performing full scan")
|
||||||
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
fullScanRequired = "1"
|
||||||
case inProgress:
|
case inProgress:
|
||||||
log.Warn(ctx, "Resuming interrupted scan")
|
log.Warn(ctx, "Resuming interrupted scan")
|
||||||
default:
|
default:
|
||||||
|
|
@ -255,7 +243,7 @@ func runInitialScan(ctx context.Context) func() error {
|
||||||
|
|
||||||
func startScanWatcher(ctx context.Context) func() error {
|
func startScanWatcher(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
if conf.Server().Scanner.WatcherWait == 0 {
|
if conf.Server.Scanner.WatcherWait == 0 {
|
||||||
log.Debug("Folder watcher is DISABLED")
|
log.Debug("Folder watcher is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -270,7 +258,7 @@ func startScanWatcher(ctx context.Context) func() error {
|
||||||
|
|
||||||
func schedulePeriodicBackup(ctx context.Context) func() error {
|
func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
schedule := conf.Server().Backup.Schedule
|
schedule := conf.Server.Backup.Schedule
|
||||||
if schedule == "" {
|
if schedule == "" {
|
||||||
log.Info(ctx, "Periodic backup is DISABLED")
|
log.Info(ctx, "Periodic backup is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -305,7 +293,7 @@ func schedulePeriodicBackup(ctx context.Context) func() error {
|
||||||
|
|
||||||
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
func scheduleDBAnalyzer(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
if !conf.Server().EnableScheduledDBAnalyze {
|
if !conf.Server.EnableScheduledDBAnalyze {
|
||||||
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
log.Info(ctx, "Scheduled DB analysis is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -339,13 +327,13 @@ func startScheduler(ctx context.Context) func() error {
|
||||||
// startInsightsCollector starts the Navidrome Insight Collector, if configured.
|
// startInsightsCollector starts the Navidrome Insight Collector, if configured.
|
||||||
func startInsightsCollector(ctx context.Context) func() error {
|
func startInsightsCollector(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
if !conf.Server().EnableInsightsCollector {
|
if !conf.Server.EnableInsightsCollector {
|
||||||
log.Info(ctx, "Insight Collector is DISABLED")
|
log.Info(ctx, "Insight Collector is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log.Info(ctx, "Starting Insight Collector")
|
log.Info(ctx, "Starting Insight Collector")
|
||||||
select {
|
select {
|
||||||
case <-time.After(conf.Server().DevInsightsInitialDelay):
|
case <-time.After(conf.Server.DevInsightsInitialDelay):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -355,23 +343,11 @@ func startInsightsCollector(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
|
|
||||||
func startJellyfinDiscovery(ctx context.Context) func() error {
|
|
||||||
return func() error {
|
|
||||||
if !conf.Server().Jellyfin.Enabled || !conf.Server().Jellyfin.AutoDiscovery {
|
|
||||||
log.Debug("Jellyfin auto-discovery is DISABLED")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
CreateJellyfinDiscovery().Serve(ctx)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// startPlaybackServer starts the Navidrome playback server, if configured.
|
// startPlaybackServer starts the Navidrome playback server, if configured.
|
||||||
// It is responsible for the Jukebox functionality
|
// It is responsible for the Jukebox functionality
|
||||||
func startPlaybackServer(ctx context.Context) func() error {
|
func startPlaybackServer(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
if !conf.Server().Jukebox.Enabled {
|
if !conf.Server.Jukebox.Enabled {
|
||||||
log.Debug("Jukebox is DISABLED")
|
log.Debug("Jukebox is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -386,26 +362,10 @@ func startPlaybackServer(ctx context.Context) func() error {
|
||||||
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
func startArtworkWorker(ctx context.Context, worker *artwork.Worker) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
log.Info(ctx, "Starting artwork worker")
|
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)
|
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
|
// scheduleArtworkHousekeeping registers the recurring missing-state and prune jobs, and
|
||||||
// reports an artwork config change without acting on it.
|
// reports an artwork config change without acting on it.
|
||||||
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) func() error {
|
||||||
|
|
@ -413,20 +373,26 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu
|
||||||
schedulerInstance := scheduler.GetInstance()
|
schedulerInstance := scheduler.GetInstance()
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
if _, err := schedulerInstance.Add(consts.ArtworkEnqueueMissingSchedule, func() {
|
||||||
outsideScan(ctx, "artwork missing-state recheck", worker.EnqueueMissingAll)
|
if err := worker.EnqueueMissingAll(ctx); err != nil {
|
||||||
|
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||||
|
}
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
log.Error(ctx, "Error scheduling artwork missing-state recheck", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := schedulerInstance.Add(consts.ArtworkPruneSchedule, func() {
|
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 {
|
}); err != nil {
|
||||||
log.Error(ctx, "Error scheduling artwork prune", err)
|
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
|
// 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).
|
// 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.EnqueueMissingAll(ctx); err != nil {
|
||||||
|
log.Error(ctx, "Error enqueueing missing artwork rechecks", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := worker.ReconcileConfig(ctx); err != nil {
|
if err := worker.ReconcileConfig(ctx); err != nil {
|
||||||
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
log.Error(ctx, "Error checking the artwork config fingerprint", err)
|
||||||
|
|
@ -439,7 +405,7 @@ func scheduleArtworkHousekeeping(ctx context.Context, worker *artwork.Worker) fu
|
||||||
func startPluginManager(ctx context.Context) func() error {
|
func startPluginManager(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
manager := GetPluginManager(ctx)
|
manager := GetPluginManager(ctx)
|
||||||
if !conf.Server().Plugins.Enabled {
|
if !conf.Server.Plugins.Enabled {
|
||||||
log.Debug("Plugin system is DISABLED")
|
log.Debug("Plugin system is DISABLED")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
|
|
@ -9,8 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/tests"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -21,13 +19,17 @@ var _ = Describe("profilerHandler", func() {
|
||||||
// Mirrors how server.MountRouter mounts the handler.
|
// Mirrors how server.MountRouter mounts the handler.
|
||||||
mount := func() http.Handler {
|
mount := func() http.Handler {
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
router.Mount(path.Join(conf.Server().BasePath, "/debug"), profilerHandler())
|
router.Mount(path.Join(conf.Server.BasePath, "/debug"), profilerHandler())
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
DescribeTable("serves a named profile",
|
DescribeTable("serves a named profile",
|
||||||
func(basePath string) {
|
func(basePath string) {
|
||||||
conf.Update(func(c *conf.Options) { c.BasePath = basePath })
|
conf.Server.BasePath = basePath
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
|
target := path.Join(basePath, "/debug/pprof/nd-profiler-test") + "?debug=1"
|
||||||
|
|
@ -42,29 +44,3 @@ var _ = Describe("profilerHandler", func() {
|
||||||
Entry("with a trailing-slash BasePath", "/music/"),
|
Entry("with a trailing-slash BasePath", "/music/"),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("librariesWithChangedPID", func() {
|
|
||||||
var ds *tests.MockDataStore
|
|
||||||
var libs *tests.MockLibraryRepo
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
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"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
|
||||||
}
|
|
||||||
37
cmd/svc.go
37
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
type svcControl struct {
|
type svcControl struct {
|
||||||
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
@ -53,13 +53,8 @@ func (p *svcControl) Start(service.Service) error {
|
||||||
p.done = make(chan struct{})
|
p.done = make(chan struct{})
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
err := runNavidrome(p.ctx)
|
runNavidrome(p.ctx)
|
||||||
close(p.done)
|
close(p.done)
|
||||||
// service.Run() only returns when it gets a stop request, so exit here to let the
|
|
||||||
// service manager see the failure and restart the service
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +74,15 @@ func (p *svcControl) Stop(service.Service) error {
|
||||||
var svcInstance = sync.OnceValue(func() service.Service {
|
var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
options := make(service.KeyValue)
|
options := make(service.KeyValue)
|
||||||
options["Restart"] = "on-failure"
|
options["Restart"] = "on-failure"
|
||||||
options["SuccessExitStatus"] = "SIGKILL"
|
options["SuccessExitStatus"] = "1 2 8 SIGKILL"
|
||||||
options["UserService"] = false
|
options["UserService"] = false
|
||||||
options["LogDirectory"] = conf.Server().DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||||
options["SystemdScript"] = systemdScript
|
options["SystemdScript"] = systemdScript
|
||||||
if conf.Server().LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
options["LogOutput"] = false
|
options["LogOutput"] = false
|
||||||
} else {
|
} else {
|
||||||
options["LogOutput"] = true
|
options["LogOutput"] = true
|
||||||
options["LogDirectory"] = conf.Server().DataFolder.String()
|
options["LogDirectory"] = conf.Server.DataFolder.String()
|
||||||
}
|
}
|
||||||
svcConfig := &service.Config{
|
svcConfig := &service.Config{
|
||||||
UserName: installUser,
|
UserName: installUser,
|
||||||
|
|
@ -101,8 +96,8 @@ var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
Option: options,
|
Option: options,
|
||||||
}
|
}
|
||||||
arguments := []string{"service", "execute"}
|
arguments := []string{"service", "execute"}
|
||||||
if conf.Server().ConfigFile != "" {
|
if conf.Server.ConfigFile != "" {
|
||||||
arguments = append(arguments, "-c", conf.Server().ConfigFile)
|
arguments = append(arguments, "-c", conf.Server.ConfigFile)
|
||||||
}
|
}
|
||||||
svcConfig.Arguments = arguments
|
svcConfig.Arguments = arguments
|
||||||
|
|
||||||
|
|
@ -135,21 +130,19 @@ func buildInstallCmd() *cobra.Command {
|
||||||
var err error
|
var err error
|
||||||
println("Installing service with:")
|
println("Installing service with:")
|
||||||
println(" working directory: " + executablePath())
|
println(" working directory: " + executablePath())
|
||||||
println(" music folder: " + conf.Server().MusicFolder)
|
println(" music folder: " + conf.Server.MusicFolder)
|
||||||
println(" data folder: " + conf.Server().DataFolder.String())
|
println(" data folder: " + conf.Server.DataFolder.String())
|
||||||
if conf.Server().LogFile != "" {
|
if conf.Server.LogFile != "" {
|
||||||
println(" log file: " + conf.Server().LogFile)
|
println(" log file: " + conf.Server.LogFile)
|
||||||
} else {
|
} else {
|
||||||
println(" logs folder: " + conf.Server().DataFolder.String())
|
println(" logs folder: " + conf.Server.DataFolder.String())
|
||||||
}
|
}
|
||||||
if cfgFile != "" {
|
if cfgFile != "" {
|
||||||
var absPath string
|
conf.Server.ConfigFile, err = filepath.Abs(cfgFile)
|
||||||
absPath, err = filepath.Abs(cfgFile)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
conf.Update(func(c *conf.Options) { c.ConfigFile = absPath })
|
println(" config file: " + conf.Server.ConfigFile)
|
||||||
println(" config file: " + absPath)
|
|
||||||
}
|
}
|
||||||
err = svcInstance().Install()
|
err = svcInstance().Install()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
err := ds.WithTx(func(tx model.DataStore) error {
|
||||||
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
return fmt.Errorf("existing user '%s'", userID)
|
return fmt.Errorf("existing user '%s'", userID)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
||||||
return libraryError(user.Libraries)
|
return libraryError(user.Libraries)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user.Libraries, err = tx.Library().GetAll(ctx)
|
user.Libraries, err = tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().Put(ctx, &user)
|
err = tx.User(ctx).Put(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
var user *model.User
|
var user *model.User
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
err = ds.WithTx(func(tx model.DataStore) error {
|
||||||
count, err := tx.User().CountAll(ctx)
|
count, err := tx.User(ctx).CountAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.User().Delete(ctx, user.ID)
|
return tx.User(ctx).Delete(user.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if setAdmin && !user.IsAdmin {
|
if setAdmin && !user.IsAdmin {
|
||||||
libraries, err := tx.Library().GetAll(ctx)
|
libraries, err := tx.Library(ctx).GetAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().Put(ctx, user)
|
err := tx.User(ctx).Put(user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -393,11 +393,13 @@ func runUserList(ctx context.Context) {
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
userList, err := ds.User().ReadAll(ctx)
|
users, err := ds.User(ctx).ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userList := users.(model.Users)
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
|
|
|
||||||
29
cmd/utils.go
29
cmd/utils.go
|
|
@ -5,11 +5,8 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core/auth"
|
"github.com/navidrome/navidrome/core/auth"
|
||||||
"github.com/navidrome/navidrome/db"
|
"github.com/navidrome/navidrome/db"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
|
@ -18,28 +15,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
)
|
)
|
||||||
|
|
||||||
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
|
|
||||||
func existingDBFile() (string, bool) {
|
|
||||||
path, _, _ := strings.Cut(conf.Server().DbPath, "?")
|
|
||||||
_, err := os.Stat(path)
|
|
||||||
return path, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireExistingDB aborts the command when the database file does not exist.
|
|
||||||
func requireExistingDB() {
|
|
||||||
if path, ok := existingDBFile(); !ok {
|
|
||||||
log.Fatal("No existing database", "path", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func confirmYES(warning string) bool {
|
|
||||||
fmt.Println(warning)
|
|
||||||
fmt.Printf("Please enter YES (all caps) to continue: ")
|
|
||||||
var input string
|
|
||||||
_, err := fmt.Scanln(&input)
|
|
||||||
return input == "YES" && err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTabWriter keeps every CLI table on the same column settings.
|
// newTabWriter keeps every CLI table on the same column settings.
|
||||||
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
func newTabWriter(out io.Writer) *tabwriter.Writer {
|
||||||
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)
|
||||||
|
|
@ -57,14 +32,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||||
user, err := ds.User().FindByUsername(ctx, id)
|
user, err := ds.User(ctx).FindByUsername(id)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
user, err = ds.User().Get(ctx, id)
|
user, err = ds.User(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/metrics"
|
"github.com/navidrome/navidrome/core/metrics"
|
||||||
"github.com/navidrome/navidrome/core/playback"
|
"github.com/navidrome/navidrome/core/playback"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"github.com/navidrome/navidrome/core/quickconnect"
|
|
||||||
"github.com/navidrome/navidrome/core/scrobbler"
|
"github.com/navidrome/navidrome/core/scrobbler"
|
||||||
"github.com/navidrome/navidrome/core/sonic"
|
"github.com/navidrome/navidrome/core/sonic"
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
|
|
@ -31,7 +30,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -72,7 +70,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||||
insights := metrics.GetInstance(dataStore)
|
insights := metrics.GetInstance(dataStore)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
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)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
manager := plugins.GetManager(dataStore, broker, metricsMetrics)
|
||||||
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
library := core.NewLibrary(dataStore, modelScanner, watcher, broker, manager)
|
||||||
|
|
@ -81,8 +79,7 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
|
||||||
agentsAgents := agents.GetAgents(dataStore, manager)
|
agentsAgents := agents.GetAgents(dataStore, manager)
|
||||||
matcherMatcher := matcher.New(dataStore)
|
matcherMatcher := matcher.New(dataStore)
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||||
quickConnect := quickconnect.GetInstance()
|
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider)
|
||||||
router := nativeapi.New(dataStore, share, playlistsPlaylists, insights, library, user, maintenance, manager, uploader, provider, quickConnect)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,9 +92,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
players := core.NewPlayers(dataStore)
|
players := core.NewPlayers(dataStore)
|
||||||
broker := events.GetBroker()
|
broker := events.GetBroker()
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
|
|
@ -107,10 +103,11 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||||
uploader := artwork.NewUploader(dataStore)
|
uploader := artwork.NewUploader(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||||
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
|
||||||
playbackServer := playback.GetInstance(dataStore)
|
playbackServer := playback.GetInstance(dataStore)
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||||
|
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
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, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic)
|
||||||
return router
|
return router
|
||||||
|
|
@ -138,15 +135,7 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
provider := external.NewProvider(dataStore, agentsAgents, matcherMatcher, broker)
|
||||||
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
sonicSonic := sonic.New(dataStore, manager, matcherMatcher)
|
||||||
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
lyricsLyrics := lyrics.NewLyrics(dataStore, manager)
|
||||||
quickConnect := quickconnect.GetInstance()
|
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, uploader)
|
||||||
router := jellyfin.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, players, playTracker, playlistsPlaylists, provider, sonicSonic, lyricsLyrics, broker, quickConnect)
|
|
||||||
return router
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
router := apiv1.New(dataStore)
|
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,10 +148,9 @@ func CreatePublicRouter() *public.Router {
|
||||||
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, imageStore, fFmpeg)
|
||||||
transcodingCache := stream.GetTranscodingCache()
|
transcodingCache := stream.GetTranscodingCache()
|
||||||
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
mediaStreamer := stream.NewMediaStreamer(dataStore, fFmpeg, transcodingCache)
|
||||||
transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg)
|
|
||||||
share := core.NewShare(dataStore)
|
share := core.NewShare(dataStore)
|
||||||
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
archiver := core.NewArchiver(mediaStreamer, dataStore, share)
|
||||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,13 +168,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
sqlDB := db.Db()
|
|
||||||
dataStore := persistence.New(sqlDB)
|
|
||||||
discovery := jellyfin.NewDiscovery(dataStore)
|
|
||||||
return discovery
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
|
@ -208,7 +189,7 @@ func CreateScanner(ctx context.Context) model.Scanner {
|
||||||
uploader := artwork.NewUploader(dataStore)
|
uploader := artwork.NewUploader(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
||||||
modelScanner := scanner.GetInstance(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
modelScanner := scanner.New(ctx, dataStore, broker, playlistsPlaylists, metricsMetrics)
|
||||||
return modelScanner
|
return modelScanner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +200,7 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
|
||||||
uploader := artwork.NewUploader(dataStore)
|
uploader := artwork.NewUploader(dataStore)
|
||||||
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
playlistsPlaylists := playlists.NewPlaylists(dataStore, uploader)
|
||||||
metricsMetrics := metrics.GetPrometheusInstance(dataStore)
|
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)
|
watcher := scanner.GetWatcher(dataStore, modelScanner)
|
||||||
return watcher
|
return watcher
|
||||||
}
|
}
|
||||||
|
|
@ -268,7 +249,7 @@ func getPluginManager() *plugins.Manager {
|
||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, 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)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
"github.com/navidrome/navidrome/server/apiv1"
|
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -37,15 +36,13 @@ var allProviders = wire.NewSet(
|
||||||
server.New,
|
server.New,
|
||||||
subsonic.New,
|
subsonic.New,
|
||||||
jellyfin.New,
|
jellyfin.New,
|
||||||
jellyfin.NewDiscovery,
|
|
||||||
apiv1.New,
|
|
||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
public.New,
|
public.New,
|
||||||
persistence.New,
|
persistence.New,
|
||||||
lastfm.NewRouter,
|
lastfm.NewRouter,
|
||||||
listenbrainz.NewRouter,
|
listenbrainz.NewRouter,
|
||||||
events.GetBroker,
|
events.GetBroker,
|
||||||
scanner.GetInstance,
|
scanner.New,
|
||||||
scanner.GetWatcher,
|
scanner.GetWatcher,
|
||||||
metrics.GetPrometheusInstance,
|
metrics.GetPrometheusInstance,
|
||||||
db.Db,
|
db.Db,
|
||||||
|
|
@ -93,12 +90,6 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
@ -117,12 +108,6 @@ func CreateListenBrainzRouter() *listenbrainz.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateJellyfinDiscovery() *jellyfin.Discovery {
|
|
||||||
panic(wire.Build(
|
|
||||||
allProviders,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateInsights() metrics.Insights {
|
func CreateInsights() metrics.Insights {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
package configtest
|
package configtest
|
||||||
|
|
||||||
import (
|
import "github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RestoreAfterEachSpec restores the config after every spec. Call it at the top level of a suite.
|
// TODO Remove this redirection and call SnapshotConfig directly from tests
|
||||||
// Ordered specs are skipped so a BeforeAll can set config for its whole container.
|
func SetupConfig() func() {
|
||||||
func RestoreAfterEachSpec() bool {
|
return conf.SnapshotConfig()
|
||||||
return ginkgo.BeforeEach(func() {
|
|
||||||
if !ginkgo.CurrentSpecReport().IsInOrderedContainer {
|
|
||||||
ginkgo.DeferCleanup(conf.SnapshotConfig())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package configtest_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConfigtest(t *testing.T) {
|
|
||||||
RegisterFailHandler(Fail)
|
|
||||||
RunSpecs(t, "Configtest Suite")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package configtest_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("RestoreAfterEachSpec", func() {
|
|
||||||
// Either spec can run first: the second one sees the first one's change if it leaks.
|
|
||||||
It("starts with the original value (A)", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(BeEmpty())
|
|
||||||
conf.Update(func(c *conf.Options) { c.UIWelcomeMessage = "A" })
|
|
||||||
})
|
|
||||||
|
|
||||||
It("starts with the original value (B)", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(BeEmpty())
|
|
||||||
conf.Update(func(c *conf.Options) { c.UIWelcomeMessage = "B" })
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("in an Ordered container", Ordered, func() {
|
|
||||||
BeforeAll(func() {
|
|
||||||
DeferCleanup(conf.SnapshotConfig())
|
|
||||||
conf.Update(func(c *conf.Options) { c.UIWelcomeMessage = "ordered" })
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sees the BeforeAll change in the first spec", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(Equal("ordered"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("still sees the BeforeAll change in later specs", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(Equal("ordered"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -3,6 +3,7 @@ package conf
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding"
|
"encoding"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -29,7 +30,7 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type configOptions struct {
|
||||||
ConfigFile string `conf:"-"`
|
ConfigFile string `conf:"-"`
|
||||||
Address string
|
Address string
|
||||||
Port int
|
Port int
|
||||||
|
|
@ -90,6 +91,7 @@ type Options struct {
|
||||||
EnableStarRating bool
|
EnableStarRating bool
|
||||||
EnableUserEditing bool
|
EnableUserEditing bool
|
||||||
EnableArtworkUpload bool
|
EnableArtworkUpload bool
|
||||||
|
EnableUserAvatarUpload bool
|
||||||
MaxImageUploadSize string
|
MaxImageUploadSize string
|
||||||
MaxImageSize string
|
MaxImageSize string
|
||||||
EnableSharing bool
|
EnableSharing bool
|
||||||
|
|
@ -160,8 +162,6 @@ type Options struct {
|
||||||
DevExternalArtistFetchMultiplier float64
|
DevExternalArtistFetchMultiplier float64
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
DevEnableMediaFileProbe bool
|
DevEnableMediaFileProbe bool
|
||||||
DevMatchEstimatedContentLength bool
|
|
||||||
DevAPIv1 bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
|
|
@ -176,7 +176,6 @@ type scannerOptions struct {
|
||||||
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
GroupAlbumReleases bool // Deprecated: Use PID.Album instead
|
||||||
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
FollowSymlinks bool // Whether to follow symlinks when scanning directories
|
||||||
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
|
IgnoreDotFolders bool // Whether to ignore folders whose name starts with a dot when scanning
|
||||||
RespectNoMedia bool // Whether to skip folders (and their subfolders) containing a .nomedia file
|
|
||||||
PurgeMissing string // Values: "never", "always", "full"
|
PurgeMissing string // Values: "never", "always", "full"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -237,8 +236,6 @@ type jellyfinOptions struct {
|
||||||
// ExposedPublicUsers is a comma-separated list of usernames to advertise on the unauthenticated
|
// 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.
|
// GET /Users/Public, so Jellyfin clients can show a login user-picker. Empty exposes no users.
|
||||||
ExposedPublicUsers string
|
ExposedPublicUsers string
|
||||||
AutoDiscovery bool
|
|
||||||
QuickConnect bool
|
|
||||||
// MaxConcurrentStreams bounds how many collection responses can stream at once. Each holds a DB
|
// 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
|
// 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.
|
// enough slow clients would take the entire pool and stall the scanner, scrobbles and the UI.
|
||||||
|
|
@ -321,11 +318,30 @@ var currentGOOS = func() string {
|
||||||
|
|
||||||
// TLSEnabled reports whether the server serves HTTPS. Both halves are required,
|
// TLSEnabled reports whether the server serves HTTPS. Both halves are required,
|
||||||
// so callers cannot infer it from the certificate alone.
|
// so callers cannot infer it from the certificate alone.
|
||||||
func (c Options) TLSEnabled() bool {
|
func (c *configOptions) TLSEnabled() bool {
|
||||||
return c.TLSCert != "" && c.TLSKey != ""
|
return c.TLSCert != "" && c.TLSKey != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var hooks []func()
|
var (
|
||||||
|
Server = &configOptions{}
|
||||||
|
hooks []func()
|
||||||
|
)
|
||||||
|
|
||||||
|
// SnapshotConfig returns a function that restores Server to its current state.
|
||||||
|
// Uses JSON round-tripping so Dir fields get fresh sync.Once values.
|
||||||
|
func SnapshotConfig() func() {
|
||||||
|
snapshot, err := json.Marshal(Server)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err))
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
var restored configOptions
|
||||||
|
if err := json.Unmarshal(snapshot, &restored); err != nil {
|
||||||
|
panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err))
|
||||||
|
}
|
||||||
|
Server = &restored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func LoadFromFile(confFile string) {
|
func LoadFromFile(confFile string) {
|
||||||
viper.SetConfigFile(confFile)
|
viper.SetConfigFile(confFile)
|
||||||
|
|
@ -354,8 +370,7 @@ func Load(noConfigDump bool) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s := clone(current.Load())
|
err := viper.Unmarshal(&Server, viper.DecodeHook(
|
||||||
err := viper.Unmarshal(s, viper.DecodeHook(
|
|
||||||
mapstructure.ComposeDecodeHookFunc(
|
mapstructure.ComposeDecodeHookFunc(
|
||||||
mapstructure.TextUnmarshallerHookFunc(),
|
mapstructure.TextUnmarshallerHookFunc(),
|
||||||
mapstructure.StringToTimeDurationHookFunc(),
|
mapstructure.StringToTimeDurationHookFunc(),
|
||||||
|
|
@ -367,35 +382,35 @@ func Load(noConfigDump bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate non-root user early, before any filesystem operations
|
// Validate non-root user early, before any filesystem operations
|
||||||
if err := s.validateEnforceNonRootUser(); err != nil {
|
if err := validateEnforceNonRootUser(); err != nil {
|
||||||
logFatal(err)
|
logFatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.CacheFolder.String() == "" {
|
if Server.CacheFolder.String() == "" {
|
||||||
s.CacheFolder = NewDir(filepath.Join(s.DataFolder.String(), "cache"))
|
Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Plugins.Enabled {
|
if Server.Plugins.Enabled {
|
||||||
if s.Plugins.Folder.String() == "" {
|
if Server.Plugins.Folder.String() == "" {
|
||||||
s.Plugins.Folder = NewDirWithPerm(filepath.Join(s.DataFolder.String(), "plugins"), 0700)
|
Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700)
|
||||||
} else {
|
} else {
|
||||||
s.Plugins.Folder = NewDirWithPerm(s.Plugins.Folder.String(), 0700)
|
Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.ConfigFile = viper.GetViper().ConfigFileUsed()
|
Server.ConfigFile = viper.GetViper().ConfigFileUsed()
|
||||||
if s.DbPath == "" {
|
if Server.DbPath == "" {
|
||||||
s.DbPath = filepath.Join(s.DataFolder.String(), consts.DefaultDbPath)
|
Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
out := os.Stderr
|
out := os.Stderr
|
||||||
if s.LogFile != "" {
|
if Server.LogFile != "" {
|
||||||
if mkErr := os.MkdirAll(filepath.Dir(s.LogFile), os.ModePerm); mkErr != nil {
|
if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil {
|
||||||
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error()))
|
||||||
}
|
}
|
||||||
out, err = os.OpenFile(s.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 {
|
if err != nil {
|
||||||
logFatal(fmt.Sprintf("Error opening log file %s: %s", s.LogFile, err.Error()))
|
logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error()))
|
||||||
}
|
}
|
||||||
log.SetOutput(out)
|
log.SetOutput(out)
|
||||||
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
} else if os.Getenv("ND_SYSTEMD_PRIORITY_LOGGING") != "" && os.Getenv("JOURNAL_STREAM") != "" {
|
||||||
|
|
@ -406,24 +421,24 @@ func Load(noConfigDump bool) {
|
||||||
log.EnableJournalFormat()
|
log.EnableJournalFormat()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.SetLevelString(s.LogLevel)
|
log.SetLevelString(Server.LogLevel)
|
||||||
log.SetLogLevels(s.DevLogLevels)
|
log.SetLogLevels(Server.DevLogLevels)
|
||||||
log.SetLogSourceLine(s.DevLogSourceLine)
|
log.SetLogSourceLine(Server.DevLogSourceLine)
|
||||||
log.SetRedacting(s.EnableLogRedacting)
|
log.SetRedacting(Server.EnableLogRedacting)
|
||||||
|
|
||||||
durationNonNegativeOrDefault(&s.SessionTimeout, consts.DefaultSessionTimeout)
|
durationNonNegativeOrDefault(&Server.SessionTimeout, consts.DefaultSessionTimeout)
|
||||||
durationNonNegativeOrDefault(&s.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh)
|
durationNonNegativeOrDefault(&Server.SmartPlaylistRefreshDelay, consts.DefaultSmartRefresh)
|
||||||
durationNonNegativeOrDefault(&s.DefaultShareExpiration, consts.DefaultShareExpiration)
|
durationNonNegativeOrDefault(&Server.DefaultShareExpiration, consts.DefaultShareExpiration)
|
||||||
durationNonNegativeOrDefault(&s.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval)
|
durationNonNegativeOrDefault(&Server.UIPlaybackReportInterval, consts.DefaultUIPlaybackReportInterval)
|
||||||
durationNonNegativeOrDefault(&s.AuthWindowLength, consts.DefaultAuthWindowLength)
|
durationNonNegativeOrDefault(&Server.AuthWindowLength, consts.DefaultAuthWindowLength)
|
||||||
durationNonNegativeOrDefault(&s.Scanner.WatcherWait, consts.DefaultWatcherWait)
|
durationNonNegativeOrDefault(&Server.Scanner.WatcherWait, consts.DefaultWatcherWait)
|
||||||
|
|
||||||
durationNonNegativeOrDefault(&s.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate)
|
durationNonNegativeOrDefault(&Server.DevActivityPanelUpdateRate, consts.DefaultActivityPanelUpdateRate)
|
||||||
durationNonNegativeOrDefault(&s.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout)
|
durationNonNegativeOrDefault(&Server.DevArtworkThrottleBacklogTimeout, consts.RequestThrottleBacklogTimeout)
|
||||||
durationNonNegativeOrDefault(&s.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive)
|
durationNonNegativeOrDefault(&Server.DevArtistInfoTimeToLive, consts.ArtistInfoTimeToLive)
|
||||||
durationNonNegativeOrDefault(&s.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive)
|
durationNonNegativeOrDefault(&Server.DevAlbumInfoTimeToLive, consts.AlbumInfoTimeToLive)
|
||||||
durationNonNegativeOrDefault(&s.DevInsightsInitialDelay, consts.InsightsInitialDelay)
|
durationNonNegativeOrDefault(&Server.DevInsightsInitialDelay, consts.InsightsInitialDelay)
|
||||||
durationNonNegativeOrDefault(&s.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout)
|
durationNonNegativeOrDefault(&Server.DevPluginCompilationTimeout, consts.DefaultPluginCompilationTimeout)
|
||||||
|
|
||||||
// Log deprecated, removed and unknown options
|
// Log deprecated, removed and unknown options
|
||||||
for _, o := range deprecatedOptions {
|
for _, o := range deprecatedOptions {
|
||||||
|
|
@ -433,35 +448,35 @@ func Load(noConfigDump bool) {
|
||||||
logUnknownOptions()
|
logUnknownOptions()
|
||||||
|
|
||||||
err = run.Sequentially(
|
err = run.Sequentially(
|
||||||
s.validateScanSchedule,
|
validateScanSchedule,
|
||||||
s.validateBackupSchedule,
|
validateBackupSchedule,
|
||||||
s.validatePlaylistsPath,
|
validatePlaylistsPath,
|
||||||
s.validatePurgeMissingOption,
|
validatePurgeMissingOption,
|
||||||
validateByteSize("MaxImageUploadSize", s.MaxImageUploadSize),
|
validateByteSize("MaxImageUploadSize", Server.MaxImageUploadSize),
|
||||||
validateByteSize("MaxImageSize", s.MaxImageSize),
|
validateByteSize("MaxImageSize", Server.MaxImageSize),
|
||||||
validateURL("ExtAuth.LogoutURL", s.ExtAuth.LogoutURL),
|
validateURL("ExtAuth.LogoutURL", Server.ExtAuth.LogoutURL),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal(err)
|
logFatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Search.Backend = normalizeSearchBackend(s.Search.Backend)
|
Server.Search.Backend = normalizeSearchBackend(Server.Search.Backend)
|
||||||
|
|
||||||
if s.BaseURL != "" {
|
if Server.BaseURL != "" {
|
||||||
u, err := url.Parse(s.BaseURL)
|
u, err := url.Parse(Server.BaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logFatal("Invalid BaseURL:", err)
|
logFatal("Invalid BaseURL:", err)
|
||||||
}
|
}
|
||||||
s.BasePath = u.Path
|
Server.BasePath = u.Path
|
||||||
u.Path = ""
|
u.Path = ""
|
||||||
u.RawQuery = ""
|
u.RawQuery = ""
|
||||||
s.BaseHost = u.Host
|
Server.BaseHost = u.Host
|
||||||
s.BaseScheme = u.Scheme
|
Server.BaseScheme = u.Scheme
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log configuration source
|
// Log configuration source
|
||||||
if s.ConfigFile != "" {
|
if Server.ConfigFile != "" {
|
||||||
log.Info("Loaded configuration", "file", s.ConfigFile)
|
log.Info("Loaded configuration", "file", Server.ConfigFile)
|
||||||
} else if hasNDEnvVars() {
|
} else if hasNDEnvVars() {
|
||||||
log.Info("No configuration file found. Loaded configuration only from environment variables")
|
log.Info("No configuration file found. Loaded configuration only from environment variables")
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -470,49 +485,42 @@ func Load(noConfigDump bool) {
|
||||||
|
|
||||||
// Print current configuration if log level is Debug
|
// Print current configuration if log level is Debug
|
||||||
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
|
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
|
||||||
prettyConf := pretty.Sprintf("Configuration: %# v", s)
|
prettyConf := pretty.Sprintf("Configuration: %# v", Server)
|
||||||
if s.EnableLogRedacting {
|
if Server.EnableLogRedacting {
|
||||||
prettyConf = log.Redact(prettyConf)
|
prettyConf = log.Redact(prettyConf)
|
||||||
}
|
}
|
||||||
_, _ = fmt.Fprintln(out, prettyConf)
|
_, _ = fmt.Fprintln(out, prettyConf)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !s.EnableExternalServices {
|
if !Server.EnableExternalServices {
|
||||||
s.disableExternalServices()
|
disableExternalServices()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure we don't have empty PIDs
|
// Make sure we don't have empty PIDs
|
||||||
s.PID.Album = cmp.Or(s.PID.Album, consts.DefaultAlbumPID)
|
Server.PID.Album = cmp.Or(Server.PID.Album, consts.DefaultAlbumPID)
|
||||||
s.PID.Track = cmp.Or(s.PID.Track, consts.DefaultTrackPID)
|
Server.PID.Track = cmp.Or(Server.PID.Track, consts.DefaultTrackPID)
|
||||||
|
|
||||||
// Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
// Parse LastFM.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||||
s.LastFM.Languages = parseLanguages(s.LastFM.Language)
|
Server.LastFM.Languages = parseLanguages(Server.LastFM.Language)
|
||||||
|
|
||||||
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
// Parse Deezer.Language into Languages slice (comma-separated, with fallback to DefaultInfoLanguage)
|
||||||
s.Deezer.Languages = parseLanguages(s.Deezer.Language)
|
Server.Deezer.Languages = parseLanguages(Server.Deezer.Language)
|
||||||
|
|
||||||
// Validate other options
|
// Validate other options
|
||||||
if s.UICoverArtSize < 200 || s.UICoverArtSize > 1200 {
|
if Server.UICoverArtSize < 200 || Server.UICoverArtSize > 1200 {
|
||||||
newValue := max(200, min(1200, s.UICoverArtSize))
|
newValue := max(200, min(1200, Server.UICoverArtSize))
|
||||||
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", s.UICoverArtSize, "newValue", newValue)
|
log.Warn("UICoverArtSize must be between 200 and 1200, clamping", "value", Server.UICoverArtSize, "newValue", newValue)
|
||||||
s.UICoverArtSize = newValue
|
Server.UICoverArtSize = newValue
|
||||||
}
|
|
||||||
|
|
||||||
if s.Scanner.Extractor != consts.DefaultScannerExtractor {
|
|
||||||
log.Warn("Invalid Scanner.Extractor, using default", "value", s.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
|
||||||
s.Scanner.Extractor = consts.DefaultScannerExtractor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||||
imgSize, _ := humanize.ParseBytes(s.MaxImageSize)
|
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||||
uploadSize, _ := humanize.ParseBytes(s.MaxImageUploadSize)
|
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||||
if imgSize < uploadSize {
|
if imgSize < uploadSize {
|
||||||
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", s.MaxImageSize, "newValue", s.MaxImageUploadSize)
|
log.Warn("MaxImageSize must be at least MaxImageUploadSize, raising", "value", Server.MaxImageSize, "newValue", Server.MaxImageUploadSize)
|
||||||
s.MaxImageSize = s.MaxImageUploadSize
|
Server.MaxImageSize = Server.MaxImageUploadSize
|
||||||
}
|
}
|
||||||
|
|
||||||
current.Store(s)
|
|
||||||
|
|
||||||
// Call init hooks
|
// Call init hooks
|
||||||
for _, hook := range hooks {
|
for _, hook := range hooks {
|
||||||
hook()
|
hook()
|
||||||
|
|
@ -753,7 +761,7 @@ var configKeys = sync.OnceValues(func() (map[string]string, []string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
collect(reflect.TypeFor[Options](), "")
|
collect(reflect.TypeFor[configOptions](), "")
|
||||||
|
|
||||||
for _, o := range deprecatedOptions {
|
for _, o := range deprecatedOptions {
|
||||||
keys[strings.ToLower(o.name)] = o.name
|
keys[strings.ToLower(o.name)] = o.name
|
||||||
|
|
@ -788,21 +796,21 @@ func parseIniFileConfiguration() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Options) disableExternalServices() {
|
func disableExternalServices() {
|
||||||
log.Info("All external integrations are DISABLED!")
|
log.Info("All external integrations are DISABLED!")
|
||||||
s.EnableInsightsCollector = false
|
Server.EnableInsightsCollector = false
|
||||||
s.EnableM3UExternalAlbumArt = false
|
Server.EnableM3UExternalAlbumArt = false
|
||||||
s.LastFM.Enabled = false
|
Server.LastFM.Enabled = false
|
||||||
s.Deezer.Enabled = false
|
Server.Deezer.Enabled = false
|
||||||
s.ListenBrainz.Enabled = false
|
Server.ListenBrainz.Enabled = false
|
||||||
s.Agents = ""
|
Server.Agents = ""
|
||||||
if s.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
|
if Server.UILoginBackgroundURL == consts.DefaultUILoginBackgroundURL {
|
||||||
s.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
Server.UILoginBackgroundURL = consts.DefaultUILoginBackgroundURLOffline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Options) validatePlaylistsPath() error {
|
func validatePlaylistsPath() error {
|
||||||
for path := range strings.SplitSeq(s.PlaylistsPath, string(filepath.ListSeparator)) {
|
for path := range strings.SplitSeq(Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||||
_, err := doublestar.Match(path, "")
|
_, err := doublestar.Match(path, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
|
return fmt.Errorf("invalid PlaylistsPath %q: %w", path, err)
|
||||||
|
|
@ -827,12 +835,12 @@ func parseLanguages(lang string) []string {
|
||||||
return languages
|
return languages
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Options) validatePurgeMissingOption() error {
|
func validatePurgeMissingOption() error {
|
||||||
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
allowedValues := []string{consts.PurgeMissingNever, consts.PurgeMissingAlways, consts.PurgeMissingFull}
|
||||||
valid := slices.Contains(allowedValues, s.Scanner.PurgeMissing)
|
valid := slices.Contains(allowedValues, Server.Scanner.PurgeMissing)
|
||||||
if !valid {
|
if !valid {
|
||||||
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", s.Scanner.PurgeMissing, allowedValues)
|
err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
|
||||||
s.Scanner.PurgeMissing = consts.PurgeMissingNever
|
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -854,8 +862,8 @@ func validateByteSize(name, value string) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Options) validateEnforceNonRootUser() error {
|
func validateEnforceNonRootUser() error {
|
||||||
if !s.EnforceNonRootUser || currentGOOS() == "windows" {
|
if !Server.EnforceNonRootUser || currentGOOS() == "windows" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -866,23 +874,23 @@ func (s *Options) validateEnforceNonRootUser() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Options) validateScanSchedule() error {
|
func validateScanSchedule() error {
|
||||||
if s.Scanner.Schedule == "0" || s.Scanner.Schedule == "" {
|
if Server.Scanner.Schedule == "0" || Server.Scanner.Schedule == "" {
|
||||||
s.Scanner.Schedule = ""
|
Server.Scanner.Schedule = ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
s.Scanner.Schedule, err = validateSchedule(s.Scanner.Schedule, "Scanner.Schedule")
|
Server.Scanner.Schedule, err = validateSchedule(Server.Scanner.Schedule, "Scanner.Schedule")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Options) validateBackupSchedule() error {
|
func validateBackupSchedule() error {
|
||||||
if s.Backup.Path.String() == "" || s.Backup.Schedule == "" || s.Backup.Count == 0 {
|
if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 {
|
||||||
s.Backup.Schedule = ""
|
Server.Backup.Schedule = ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var err error
|
var err error
|
||||||
s.Backup.Schedule, err = validateSchedule(s.Backup.Schedule, "Backup.Schedule")
|
Server.Backup.Schedule, err = validateSchedule(Server.Backup.Schedule, "Backup.Schedule")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1021,6 +1029,7 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("enablenowplaying", true)
|
viper.SetDefault("enablenowplaying", true)
|
||||||
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval)
|
||||||
viper.SetDefault("enableartworkupload", true)
|
viper.SetDefault("enableartworkupload", true)
|
||||||
|
viper.SetDefault("enableuseravatarupload", true)
|
||||||
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize)
|
||||||
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
viper.SetDefault("maximagesize", consts.DefaultMaxImageSize)
|
||||||
viper.SetDefault("enablesharing", true)
|
viper.SetDefault("enablesharing", true)
|
||||||
|
|
@ -1055,7 +1064,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("scanner.groupalbumreleases", false)
|
viper.SetDefault("scanner.groupalbumreleases", false)
|
||||||
viper.SetDefault("scanner.followsymlinks", true)
|
viper.SetDefault("scanner.followsymlinks", true)
|
||||||
viper.SetDefault("scanner.ignoredotfolders", true)
|
viper.SetDefault("scanner.ignoredotfolders", true)
|
||||||
viper.SetDefault("scanner.respectnomedia", false)
|
|
||||||
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
viper.SetDefault("scanner.purgemissing", consts.PurgeMissingNever)
|
||||||
viper.SetDefault("subsonic.appendsubtitle", true)
|
viper.SetDefault("subsonic.appendsubtitle", true)
|
||||||
viper.SetDefault("subsonic.appendalbumversion", true)
|
viper.SetDefault("subsonic.appendalbumversion", true)
|
||||||
|
|
@ -1081,8 +1089,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
viper.SetDefault("listenbrainz.trackalgorithm", consts.DefaultListenBrainzTrackAlgorithm)
|
||||||
viper.SetDefault("jellyfin.enabled", false)
|
viper.SetDefault("jellyfin.enabled", false)
|
||||||
viper.SetDefault("jellyfin.servername", "")
|
viper.SetDefault("jellyfin.servername", "")
|
||||||
viper.SetDefault("jellyfin.autodiscovery", false)
|
|
||||||
viper.SetDefault("jellyfin.quickconnect", true)
|
|
||||||
viper.SetDefault("enablescrobblehistory", true)
|
viper.SetDefault("enablescrobblehistory", true)
|
||||||
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
viper.SetDefault("httpheaders.frameoptions", "DENY")
|
||||||
viper.SetDefault("backup.path", "")
|
viper.SetDefault("backup.path", "")
|
||||||
|
|
@ -1111,7 +1117,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
viper.SetDefault("devapiv1", false)
|
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||||
// scrobbles and the UI. See MaxOpenConns.
|
// scrobbles and the UI. See MaxOpenConns.
|
||||||
|
|
@ -1138,7 +1143,6 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
|
||||||
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
viper.SetDefault("devpreserveunicodeinexternalcalls", false)
|
||||||
viper.SetDefault("devenablemediafileprobe", true)
|
viper.SetDefault("devenablemediafileprobe", true)
|
||||||
viper.SetDefault("devmatchestimatedcontentlength", true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -23,8 +22,6 @@ func TestConfiguration(t *testing.T) {
|
||||||
RunSpecs(t, "Configuration Suite")
|
RunSpecs(t, "Configuration Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
||||||
var _ = Describe("Configuration", func() {
|
var _ = Describe("Configuration", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
// Reset viper configuration
|
// Reset viper configuration
|
||||||
|
|
@ -69,13 +66,20 @@ var _ = Describe("Configuration", func() {
|
||||||
Describe("scheduled DB analysis", func() {
|
Describe("scheduled DB analysis", func() {
|
||||||
It("is enabled by default", func() {
|
It("is enabled by default", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
Expect(conf.Server().EnableScheduledDBAnalyze).To(BeTrue())
|
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("can be disabled", func() {
|
It("can be disabled", func() {
|
||||||
viper.Set("enablescheduleddbanalyze", false)
|
viper.Set("enablescheduleddbanalyze", false)
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
Expect(conf.Server().EnableScheduledDBAnalyze).To(BeFalse())
|
Expect(conf.Server.EnableScheduledDBAnalyze).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("user avatar upload", func() {
|
||||||
|
It("is enabled by default", func() {
|
||||||
|
conf.Load(true)
|
||||||
|
Expect(conf.Server.EnableUserAvatarUpload).To(BeTrue())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -160,9 +164,9 @@ var _ = Describe("Configuration", func() {
|
||||||
conf.InitConfig(filename, false)
|
conf.InitConfig(filename, false)
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
Expect(conf.Server().Address).To(Equal("127.0.0.1"))
|
Expect(conf.Server.Address).To(Equal("127.0.0.1"))
|
||||||
Expect(conf.Server().Port).To(Equal(4531))
|
Expect(conf.Server.Port).To(Equal(4531))
|
||||||
Expect(conf.Server().Scanner.Schedule).To(Equal("@every 1h"))
|
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("exits with fatal error when both ND_ and canonical key exist", func() {
|
It("exits with fatal error when both ND_ and canonical key exist", func() {
|
||||||
|
|
@ -182,7 +186,7 @@ var _ = Describe("Configuration", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
// Verify normal config loading still works
|
// Verify normal config loading still works
|
||||||
Expect(conf.Server().MusicFolder).To(Equal("/toml/music"))
|
Expect(conf.Server.MusicFolder).To(Equal("/toml/music"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -256,14 +260,14 @@ var _ = Describe("Configuration", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION"))
|
Expect(conf.UnknownConfigKeys()).To(ConsistOf("ND_TOTALLY_BOGUS_OPTION"))
|
||||||
Expect(conf.Server().Scanner.Schedule).To(Equal("@every 1h"))
|
Expect(conf.Server.Scanner.Schedule).To(Equal("@every 1h"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("migrates every deprecated option that has a replacement", func() {
|
It("migrates every deprecated option that has a replacement", func() {
|
||||||
conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false)
|
conf.InitConfig(filepath.Join("testdata", "cfg_deprecated_search.toml"), false)
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
Expect(conf.Server().Search.FullString).To(BeTrue())
|
Expect(conf.Server.Search.FullString).To(BeTrue())
|
||||||
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
Expect(conf.UnknownConfigKeys()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -298,7 +302,7 @@ var _ = Describe("Configuration", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("keeps an explicit replacement over the deprecated value", func() {
|
It("keeps an explicit replacement over the deprecated value", func() {
|
||||||
Expect(conf.Server().Search.FullString).To(BeFalse())
|
Expect(conf.Server.Search.FullString).To(BeFalse())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -332,21 +336,6 @@ var _ = Describe("Configuration", func() {
|
||||||
}).To(PanicWith(ContainSubstring("Error creating log file directory")))
|
}).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() {
|
It("is called when BaseURL is invalid", func() {
|
||||||
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||||
viper.SetDefault("baseurl", "://invalid")
|
viper.SetDefault("baseurl", "://invalid")
|
||||||
|
|
@ -394,34 +383,13 @@ var _ = Describe("Configuration", func() {
|
||||||
viper.SetDefault("maximagesize", "5MB")
|
viper.SetDefault("maximagesize", "5MB")
|
||||||
viper.SetDefault("maximageuploadsize", "50MB")
|
viper.SetDefault("maximageuploadsize", "50MB")
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
Expect(conf.Server().MaxImageSize).To(Equal("50MB"))
|
Expect(conf.Server.MaxImageSize).To(Equal("50MB"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("keeps a larger MaxImageSize unchanged", func() {
|
It("keeps a larger MaxImageSize unchanged", func() {
|
||||||
viper.SetDefault("maximagesize", "30MB")
|
viper.SetDefault("maximagesize", "30MB")
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
Expect(conf.Server().MaxImageSize).To(Equal("30MB"))
|
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"))
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -429,7 +397,7 @@ var _ = Describe("Configuration", func() {
|
||||||
It("defaults to false", func() {
|
It("defaults to false", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
Expect(conf.Server().EnforceNonRootUser).To(BeFalse())
|
Expect(conf.Server.EnforceNonRootUser).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("allows startup for non-root users when enabled", func() {
|
It("allows startup for non-root users when enabled", func() {
|
||||||
|
|
@ -438,7 +406,7 @@ var _ = Describe("Configuration", func() {
|
||||||
|
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
Expect(conf.Server().EnforceNonRootUser).To(BeTrue())
|
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("exits when enabled and running as root without having created a data folder", func() {
|
It("exits when enabled and running as root without having created a data folder", func() {
|
||||||
|
|
@ -464,7 +432,7 @@ var _ = Describe("Configuration", func() {
|
||||||
|
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
Expect(conf.Server().EnforceNonRootUser).To(BeTrue())
|
Expect(conf.Server.EnforceNonRootUser).To(BeTrue())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -478,16 +446,16 @@ var _ = Describe("Configuration", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
// Execute the format-specific assertions
|
// Execute the format-specific assertions
|
||||||
Expect(conf.Server().MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(Equal("Welcome " + format))
|
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
|
||||||
Expect(conf.Server().Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
|
||||||
Expect(conf.Server().Tags["artist"].Split).To(Equal([]string{";"}))
|
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
|
||||||
|
|
||||||
// Check deprecated option mapping
|
// Check deprecated option mapping
|
||||||
Expect(conf.Server().ExtAuth.UserHeader).To(Equal("X-Auth-User"))
|
Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
|
||||||
|
|
||||||
// The config file used should be the one we created
|
// The config file used should be the one we created
|
||||||
Expect(conf.Server().ConfigFile).To(Equal(filename))
|
Expect(conf.Server.ConfigFile).To(Equal(filename))
|
||||||
},
|
},
|
||||||
Entry("TOML format", "toml"),
|
Entry("TOML format", "toml"),
|
||||||
Entry("YAML format", "yaml"),
|
Entry("YAML format", "yaml"),
|
||||||
|
|
@ -500,7 +468,7 @@ var _ = Describe("Configuration", func() {
|
||||||
conf.InitConfig(filename, false)
|
conf.InitConfig(filename, false)
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
||||||
server := conf.Server()
|
server := conf.Server
|
||||||
Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout))
|
Expect(server.SessionTimeout).To(Equal(consts.DefaultSessionTimeout))
|
||||||
Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh))
|
Expect(server.SmartPlaylistRefreshDelay).To(Equal(consts.DefaultSmartRefresh))
|
||||||
Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration))
|
Expect(server.DefaultShareExpiration).To(Equal(consts.DefaultShareExpiration))
|
||||||
|
|
@ -522,7 +490,7 @@ var _ = Describe("Configuration", func() {
|
||||||
|
|
||||||
configured := 1 * time.Second
|
configured := 1 * time.Second
|
||||||
|
|
||||||
server := conf.Server()
|
server := conf.Server
|
||||||
Expect(server.SessionTimeout).To(Equal(configured))
|
Expect(server.SessionTimeout).To(Equal(configured))
|
||||||
Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured))
|
Expect(server.SmartPlaylistRefreshDelay).To(Equal(configured))
|
||||||
Expect(server.DefaultShareExpiration).To(Equal(configured))
|
Expect(server.DefaultShareExpiration).To(Equal(configured))
|
||||||
|
|
@ -540,26 +508,27 @@ var _ = Describe("Configuration", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
var _ = Describe("TLSEnabled", func() {
|
var _ = Describe("TLSEnabled", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
It("is false when neither the certificate nor the key is set", func() {
|
It("is false when neither the certificate nor the key is set", func() {
|
||||||
Expect(conf.Server().TLSEnabled()).To(BeFalse())
|
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is true when both the certificate and the key are set", func() {
|
It("is true when both the certificate and the key are set", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.TLSCert = "cert.pem"
|
||||||
c.TLSCert = "cert.pem"
|
conf.Server.TLSKey = "key.pem"
|
||||||
c.TLSKey = "key.pem"
|
Expect(conf.Server.TLSEnabled()).To(BeTrue())
|
||||||
})
|
|
||||||
Expect(conf.Server().TLSEnabled()).To(BeTrue())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is false when only the certificate is set", func() {
|
It("is false when only the certificate is set", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.TLSCert = "cert.pem" })
|
conf.Server.TLSCert = "cert.pem"
|
||||||
Expect(conf.Server().TLSEnabled()).To(BeFalse())
|
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is false when only the key is set", func() {
|
It("is false when only the key is set", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.TLSKey = "key.pem" })
|
conf.Server.TLSKey = "key.pem"
|
||||||
Expect(conf.Server().TLSEnabled()).To(BeFalse())
|
Expect(conf.Server.TLSEnabled()).To(BeFalse())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package conf
|
package conf
|
||||||
|
|
||||||
func ResetConf() {
|
func ResetConf() {
|
||||||
current.Store(&Options{})
|
Server = &configOptions{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var SetViperDefaults = setViperDefaults
|
var SetViperDefaults = setViperDefaults
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
package conf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
var current atomic.Pointer[Options]
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
current.Store(&Options{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server returns a copy of the current configuration. A published Options is never changed in place,
|
|
||||||
// so readers need no locking. Use Update to change it.
|
|
||||||
func Server() Options {
|
|
||||||
return *current.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies fn to a deep copy of the current configuration and publishes the result.
|
|
||||||
// fn may run more than once if another change lands first, so it must only set fields.
|
|
||||||
func Update(fn func(*Options)) {
|
|
||||||
for {
|
|
||||||
old := current.Load()
|
|
||||||
next := clone(old)
|
|
||||||
fn(next)
|
|
||||||
if current.CompareAndSwap(old, next) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SnapshotConfig returns a function that restores the configuration to its current state.
|
|
||||||
func SnapshotConfig() func() {
|
|
||||||
snapshot := current.Load()
|
|
||||||
return func() { current.Store(snapshot) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func clone(o *Options) *Options {
|
|
||||||
var c Options
|
|
||||||
deepCopy(reflect.ValueOf(&c).Elem(), reflect.ValueOf(o).Elem())
|
|
||||||
return &c
|
|
||||||
}
|
|
||||||
|
|
||||||
func deepCopy(dst, src reflect.Value) {
|
|
||||||
switch src.Kind() {
|
|
||||||
case reflect.Struct:
|
|
||||||
dst.Set(src)
|
|
||||||
for i := range src.NumField() {
|
|
||||||
if dst.Field(i).CanSet() {
|
|
||||||
deepCopy(dst.Field(i), src.Field(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case reflect.Map:
|
|
||||||
if src.IsNil() {
|
|
||||||
dst.Set(src)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m := reflect.MakeMapWithSize(src.Type(), src.Len())
|
|
||||||
for k, v := range src.Seq2() {
|
|
||||||
nv := reflect.New(v.Type()).Elem()
|
|
||||||
deepCopy(nv, v)
|
|
||||||
m.SetMapIndex(k, nv)
|
|
||||||
}
|
|
||||||
dst.Set(m)
|
|
||||||
case reflect.Slice:
|
|
||||||
if src.IsNil() {
|
|
||||||
dst.Set(src)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s := reflect.MakeSlice(src.Type(), src.Len(), src.Len())
|
|
||||||
for i := range src.Len() {
|
|
||||||
deepCopy(s.Index(i), src.Index(i))
|
|
||||||
}
|
|
||||||
dst.Set(s)
|
|
||||||
case reflect.Pointer:
|
|
||||||
if src.IsNil() {
|
|
||||||
dst.Set(src)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p := reflect.New(src.Type().Elem())
|
|
||||||
deepCopy(p.Elem(), src.Elem())
|
|
||||||
dst.Set(p)
|
|
||||||
default:
|
|
||||||
dst.Set(src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
package conf_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("Server state", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.Tags = map[string]conf.TagConf{"genre": {Aliases: []string{"genre", "style"}}}
|
|
||||||
c.DevLogLevels = map[string]string{"scanner": "info"}
|
|
||||||
c.Scanner.ArtistSplitExceptions = []string{"AC/DC"}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps a copy taken from Server unchanged by later updates", func() {
|
|
||||||
before := conf.Server()
|
|
||||||
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.Tags["genre"].Aliases[0] = "changed"
|
|
||||||
c.DevLogLevels["scanner"] = "debug"
|
|
||||||
c.Scanner.ArtistSplitExceptions[0] = "changed"
|
|
||||||
})
|
|
||||||
|
|
||||||
Expect(before.Tags["genre"].Aliases).To(Equal([]string{"genre", "style"}))
|
|
||||||
Expect(before.DevLogLevels).To(Equal(map[string]string{"scanner": "info"}))
|
|
||||||
Expect(before.Scanner.ArtistSplitExceptions).To(Equal([]string{"AC/DC"}))
|
|
||||||
Expect(conf.Server().DevLogLevels["scanner"]).To(Equal("debug"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("restores map and slice contents changed in place by Update", func() {
|
|
||||||
restore := conf.SnapshotConfig()
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.Tags["genre"].Aliases[0] = "changed"
|
|
||||||
c.DevLogLevels["scanner"] = "debug"
|
|
||||||
})
|
|
||||||
|
|
||||||
restore()
|
|
||||||
|
|
||||||
Expect(conf.Server().Tags["genre"].Aliases).To(Equal([]string{"genre", "style"}))
|
|
||||||
Expect(conf.Server().DevLogLevels).To(Equal(map[string]string{"scanner": "info"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("lets readers run while the config is updated and restored", func() {
|
|
||||||
stop := make(chan struct{})
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Go(func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
_ = conf.Server().DevLogLevels["scanner"]
|
|
||||||
_ = conf.Server().Scanner.Enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 100 {
|
|
||||||
restore := conf.SnapshotConfig()
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.DevLogLevels["scanner"] = "debug"
|
|
||||||
c.Scanner.Enabled = !c.Scanner.Enabled
|
|
||||||
})
|
|
||||||
restore()
|
|
||||||
}
|
|
||||||
close(stop)
|
|
||||||
wg.Wait()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps every change when updates run at the same time", func() {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Port = 0 })
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for range 50 {
|
|
||||||
wg.Go(func() {
|
|
||||||
for range 20 {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Port++ })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
Expect(conf.Server().Port).To(Equal(1000))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -49,7 +49,6 @@ const (
|
||||||
DefaultEncryptionKey = "just for obfuscation"
|
DefaultEncryptionKey = "just for obfuscation"
|
||||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||||
APIKeyPrefix = "nds_"
|
|
||||||
|
|
||||||
DevInitialUserName = "admin"
|
DevInitialUserName = "admin"
|
||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
|
|
@ -60,7 +59,6 @@ const (
|
||||||
URLPathPublic = "/share"
|
URLPathPublic = "/share"
|
||||||
URLPathPublicImages = URLPathPublic + "/img"
|
URLPathPublicImages = URLPathPublic + "/img"
|
||||||
URLPathJellyfinAPI = "/jellyfin"
|
URLPathJellyfinAPI = "/jellyfin"
|
||||||
URLPathAPIv1 = "/api/v1"
|
|
||||||
|
|
||||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
// 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.
|
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||||
|
|
@ -90,8 +88,8 @@ const (
|
||||||
|
|
||||||
I18nFolder = "i18n"
|
I18nFolder = "i18n"
|
||||||
ScanIgnoreFile = ".ndignore"
|
ScanIgnoreFile = ".ndignore"
|
||||||
NoMediaFile = ".nomedia"
|
|
||||||
ArtworkFolder = "artwork"
|
ArtworkFolder = "artwork"
|
||||||
|
AvatarFolder = "avatar"
|
||||||
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
|
// HashedArtworkFolder is a subtree of ArtworkFolder, kept apart from the name-addressed
|
||||||
// upload folders beside it so Prune's sweep never reaches them.
|
// upload folders beside it so Prune's sweep never reaches them.
|
||||||
HashedArtworkFolder = "hashed"
|
HashedArtworkFolder = "hashed"
|
||||||
|
|
@ -121,6 +119,7 @@ const (
|
||||||
DefaultUICoverArtSize = 300
|
DefaultUICoverArtSize = 300
|
||||||
DefaultMaxImageUploadSize = "10MB"
|
DefaultMaxImageUploadSize = "10MB"
|
||||||
DefaultMaxImageSize = "20MB"
|
DefaultMaxImageSize = "20MB"
|
||||||
|
MaxAvatarSize = 512
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prometheus options
|
// Prometheus options
|
||||||
|
|
@ -146,6 +145,7 @@ const (
|
||||||
EntityArtist = "artist"
|
EntityArtist = "artist"
|
||||||
EntityPlaylist = "playlist"
|
EntityPlaylist = "playlist"
|
||||||
EntityRadio = "radio"
|
EntityRadio = "radio"
|
||||||
|
EntityUser = "user"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -157,6 +157,8 @@ const (
|
||||||
//DefaultAlbumPID = "album_legacy"
|
//DefaultAlbumPID = "album_legacy"
|
||||||
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
DefaultAlbumPID = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||||
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
DefaultTrackPID = "musicbrainz_trackid|albumid,discnumber,tracknumber,title"
|
||||||
|
PIDAlbumKey = "PIDAlbum"
|
||||||
|
PIDTrackKey = "PIDTrack"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ RestrictNamespaces=yes
|
||||||
RestrictRealtime=yes
|
RestrictRealtime=yes
|
||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
SystemCallFilter=~@privileged @resources
|
SystemCallFilter=~@privileged @resources
|
||||||
SystemCallFilter=setrlimit mbind
|
SystemCallFilter=setrlimit
|
||||||
SystemCallArchitectures=native
|
SystemCallArchitectures=native
|
||||||
UMask=0066
|
UMask=0066
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ type enabledAgent struct {
|
||||||
// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false)
|
// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false)
|
||||||
func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||||
// If no agents configured, ONLY use the local agent
|
// If no agents configured, ONLY use the local agent
|
||||||
if conf.Server().Agents == "" {
|
if conf.Server.Agents == "" {
|
||||||
return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
|
return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||||
}
|
}
|
||||||
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
||||||
|
|
||||||
configuredAgents := strings.Split(conf.Server().Agents, ",")
|
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||||
|
|
||||||
// Always add LocalAgentName if not already included
|
// Always add LocalAgentName if not already included
|
||||||
hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
|
hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
|
||||||
|
|
@ -181,7 +181,7 @@ type AlbumImageAgent struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever,
|
// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever,
|
||||||
// in conf.Server().Agents order (same order the aggregate dispatch uses).
|
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||||
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
||||||
var result []ArtistImageAgent
|
var result []ArtistImageAgent
|
||||||
for _, ea := range a.getEnabledAgentNames() {
|
for _, ea := range a.getEnabledAgentNames() {
|
||||||
|
|
@ -193,7 +193,7 @@ func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever,
|
// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever,
|
||||||
// in conf.Server().Agents order (same order the aggregate dispatch uses).
|
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||||
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
|
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
|
||||||
var result []AlbumImageAgent
|
var result []AlbumImageAgent
|
||||||
for _, ea := range a.getEnabledAgentNames() {
|
for _, ea := range a.getEnabledAgentNames() {
|
||||||
|
|
@ -256,7 +256,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
||||||
// agent may not exist in the database, return at most limit * conf.Server().DevExternalArtistFetchMultiplier items.
|
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
||||||
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||||
switch id {
|
switch id {
|
||||||
case consts.UnknownArtistID:
|
case consts.UnknownArtistID:
|
||||||
|
|
@ -265,7 +265,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
overLimit := int(float64(limit) * conf.Server().DevExternalArtistFetchMultiplier)
|
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
attempts := newAttempts(&a.cooldowns)
|
attempts := newAttempts(&a.cooldowns)
|
||||||
|
|
@ -316,7 +316,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
||||||
// agent may not exist in the database, return at most limit * conf.Server().DevExternalArtistFetchMultiplier items.
|
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
||||||
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||||
switch id {
|
switch id {
|
||||||
case consts.UnknownArtistID:
|
case consts.UnknownArtistID:
|
||||||
|
|
@ -325,7 +325,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
overLimit := int(float64(count) * conf.Server().DevExternalArtistFetchMultiplier)
|
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||||
|
|
||||||
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
||||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
Context("Dynamic agent discovery", func() {
|
Context("Dynamic agent discovery", func() {
|
||||||
It("should include ONLY local agent when no config is specified", func() {
|
It("should include ONLY local agent when no config is specified", func() {
|
||||||
// Ensure no specific agents are configured
|
// Ensure no specific agents are configured
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
|
|
||||||
// Add some plugin agents that should be ignored
|
// Add some plugin agents that should be ignored
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
|
||||||
|
|
@ -82,7 +82,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
|
|
||||||
It("should NOT include plugin agents when no config is specified", func() {
|
It("should NOT include plugin agents when no config is specified", func() {
|
||||||
// Ensure no specific agents are configured
|
// Ensure no specific agents are configured
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
|
|
||||||
// Add a plugin agent
|
// Add a plugin agent
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
@ -99,13 +99,13 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
||||||
// With no config, should not include plugin
|
// With no config, should not include plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
enabledAgents := agents.getEnabledAgentNames()
|
enabledAgents := agents.getEnabledAgentNames()
|
||||||
Expect(enabledAgents).To(HaveLen(1))
|
Expect(enabledAgents).To(HaveLen(1))
|
||||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||||
|
|
||||||
// When explicitly configured, should include plugin
|
// When explicitly configured, should include plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_agent" })
|
conf.Server.Agents = "plugin_agent"
|
||||||
enabledAgents = agents.getEnabledAgentNames()
|
enabledAgents = agents.getEnabledAgentNames()
|
||||||
var agentNames []string
|
var agentNames []string
|
||||||
var pluginAgentFound bool
|
var pluginAgentFound bool
|
||||||
|
|
@ -125,7 +125,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two")
|
||||||
|
|
||||||
// Configure only one of them
|
// Configure only one of them
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_one" })
|
conf.Server.Agents = "plugin_one"
|
||||||
|
|
||||||
// Verify only the configured one is included
|
// Verify only the configured one is included
|
||||||
enabledAgents := agents.getEnabledAgentNames()
|
enabledAgents := agents.getEnabledAgentNames()
|
||||||
|
|
@ -147,7 +147,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Configure to use our plugin
|
// Configure to use our plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_agent" })
|
conf.Server.Agents = "plugin_agent"
|
||||||
|
|
||||||
// Add a plugin agent
|
// Add a plugin agent
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
@ -177,7 +177,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Configure to use both built-in and plugin
|
// Configure to use both built-in and plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "built_in,plugin_agent" })
|
conf.Server.Agents = "built_in,plugin_agent"
|
||||||
|
|
||||||
// Add a plugin agent
|
// Add a plugin agent
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
@ -223,7 +223,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y")
|
||||||
|
|
||||||
// Configure specific order - plugin first, then built-ins
|
// Configure specific order - plugin first, then built-ins
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_y,agent_b,plugin_x,agent_a" })
|
conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a"
|
||||||
|
|
||||||
// Get the agent names
|
// Get the agent names
|
||||||
enabledAgents := agents.getEnabledAgentNames()
|
enabledAgents := agents.getEnabledAgentNames()
|
||||||
|
|
@ -250,7 +250,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Configure to use only built-in agents
|
// Configure to use only built-in agents
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "builtin_agent" })
|
conf.Server.Agents = "builtin_agent"
|
||||||
|
|
||||||
// Call GetArtistMBID which should only use the built-in agent
|
// Call GetArtistMBID which should only use the built-in agent
|
||||||
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||||
|
|
@ -266,7 +266,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Configure with an invalid agent name (not built-in, not a plugin)
|
// Configure with an invalid agent name (not built-in, not a plugin)
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "invalid_agent" })
|
conf.Server.Agents = "invalid_agent"
|
||||||
|
|
||||||
// This should only result in using the local agent (as the invalid one is ignored)
|
// This should only result in using the local agent (as the invalid one is ignored)
|
||||||
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package agents
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestAgents(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Agents Test Suite")
|
RunSpecs(t, "Agents Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
|
@ -44,6 +45,7 @@ var _ = Describe("Agents", func() {
|
||||||
var ds model.DataStore
|
var ds model.DataStore
|
||||||
var mfRepo *tests.MockMediaFileRepo
|
var mfRepo *tests.MockMediaFileRepo
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx, cancel = context.WithCancel(context.Background())
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
mfRepo = tests.CreateMockMediaFileRepo()
|
mfRepo = tests.CreateMockMediaFileRepo()
|
||||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||||
|
|
@ -52,7 +54,7 @@ var _ = Describe("Agents", func() {
|
||||||
Describe("Local", func() {
|
Describe("Local", func() {
|
||||||
var ag *Agents
|
var ag *Agents
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -72,7 +74,7 @@ var _ = Describe("Agents", func() {
|
||||||
Register("fake", func(model.DataStore) Interface { return mock })
|
Register("fake", func(model.DataStore) Interface { return mock })
|
||||||
Register("disabled", func(model.DataStore) Interface { return nil })
|
Register("disabled", func(model.DataStore) Interface { return nil })
|
||||||
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
|
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "empty,fake,disabled" })
|
conf.Server.Agents = "empty,fake,disabled"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
Expect(ag.AgentName()).To(Equal("agents"))
|
Expect(ag.AgentName()).To(Equal("agents"))
|
||||||
})
|
})
|
||||||
|
|
@ -250,7 +252,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
|
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
|
||||||
other := &mockAgent{Err: ErrNotFound}
|
other := &mockAgent{Err: ErrNotFound}
|
||||||
Register("fake2", func(model.DataStore) Interface { return other })
|
Register("fake2", func(model.DataStore) Interface { return other })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "fake,fake2" })
|
conf.Server.Agents = "fake,fake2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||||
|
|
||||||
|
|
@ -267,7 +269,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("returns ErrNotFound when another agent answered with an empty slice", func() {
|
It("returns ErrNotFound when another agent answered with an empty slice", func() {
|
||||||
empty := &testImageAgent{Name: "emptyImages"}
|
empty := &testImageAgent{Name: "emptyImages"}
|
||||||
Register("emptyImages", func(model.DataStore) Interface { return empty })
|
Register("emptyImages", func(model.DataStore) Interface { return empty })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "fake,emptyImages" })
|
conf.Server.Agents = "fake,emptyImages"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||||
|
|
||||||
|
|
@ -285,7 +287,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
|
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
|
||||||
other := &mockAgent{Err: ErrNotFound}
|
other := &mockAgent{Err: ErrNotFound}
|
||||||
Register("fake2", func(model.DataStore) Interface { return other })
|
Register("fake2", func(model.DataStore) Interface { return other })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "fake,fake2" })
|
conf.Server.Agents = "fake,fake2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||||
|
|
||||||
|
|
@ -328,7 +330,7 @@ var _ = Describe("Agents", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to the next agent on error", func() {
|
It("falls back to the next agent on error", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "imgFail,imgOk" })
|
conf.Server.Agents = "imgFail,imgOk"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||||
|
|
@ -341,7 +343,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("falls back if the first agent returns no images", func() {
|
It("falls back if the first agent returns no images", func() {
|
||||||
first.Err = nil
|
first.Err = nil
|
||||||
first.Images = []ExternalImage{}
|
first.Images = []ExternalImage{}
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "imgFail,imgOk" })
|
conf.Server.Agents = "imgFail,imgOk"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||||
|
|
@ -377,7 +379,7 @@ var _ = Describe("Agents", func() {
|
||||||
|
|
||||||
Describe("GetArtistTopSongs", func() {
|
Describe("GetArtistTopSongs", func() {
|
||||||
It("returns on first match", func() {
|
It("returns on first match", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevExternalArtistFetchMultiplier = 1 })
|
conf.Server.DevExternalArtistFetchMultiplier = 1
|
||||||
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
||||||
Name: "A Song",
|
Name: "A Song",
|
||||||
MBID: "mbid444",
|
MBID: "mbid444",
|
||||||
|
|
@ -385,7 +387,7 @@ var _ = Describe("Agents", func() {
|
||||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||||
})
|
})
|
||||||
It("skips the agent if it returns an error", func() {
|
It("skips the agent if it returns an error", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevExternalArtistFetchMultiplier = 1 })
|
conf.Server.DevExternalArtistFetchMultiplier = 1
|
||||||
mock.Err = errors.New("error")
|
mock.Err = errors.New("error")
|
||||||
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
||||||
Expect(err).To(MatchError(ErrNotFound))
|
Expect(err).To(MatchError(ErrNotFound))
|
||||||
|
|
@ -398,7 +400,7 @@ var _ = Describe("Agents", func() {
|
||||||
Expect(mock.Args).To(BeEmpty())
|
Expect(mock.Args).To(BeEmpty())
|
||||||
})
|
})
|
||||||
It("fetches with multiplier", func() {
|
It("fetches with multiplier", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevExternalArtistFetchMultiplier = 2 })
|
conf.Server.DevExternalArtistFetchMultiplier = 2
|
||||||
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
||||||
Name: "A Song",
|
Name: "A Song",
|
||||||
MBID: "mbid444",
|
MBID: "mbid444",
|
||||||
|
|
@ -517,7 +519,7 @@ var _ = Describe("Agents", func() {
|
||||||
|
|
||||||
Describe("ArtistImageAgents", func() {
|
Describe("ArtistImageAgents", func() {
|
||||||
It("returns only ArtistImageRetriever agents, named, in configured order", func() {
|
It("returns only ArtistImageRetriever agents, named, in configured order", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "artistImg,noImages,artistImg2" })
|
conf.Server.Agents = "artistImg,noImages,artistImg2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
result := ag.ArtistImageAgents()
|
result := ag.ArtistImageAgents()
|
||||||
|
|
@ -529,7 +531,7 @@ var _ = Describe("Agents", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is empty when external services are disabled", func() {
|
It("is empty when external services are disabled", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" }) // what disableExternalServices() sets when EnableExternalServices=false
|
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
Expect(ag.ArtistImageAgents()).To(BeEmpty())
|
Expect(ag.ArtistImageAgents()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -537,7 +539,7 @@ var _ = Describe("Agents", func() {
|
||||||
|
|
||||||
Describe("AlbumImageAgents", func() {
|
Describe("AlbumImageAgents", func() {
|
||||||
It("returns only AlbumImageRetriever agents, named, in configured order", func() {
|
It("returns only AlbumImageRetriever agents, named, in configured order", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "albumImg,noImages,albumImg2" })
|
conf.Server.Agents = "albumImg,noImages,albumImg2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
result := ag.AlbumImageAgents()
|
result := ag.AlbumImageAgents()
|
||||||
|
|
@ -549,7 +551,7 @@ var _ = Describe("Agents", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is empty when external services are disabled", func() {
|
It("is empty when external services are disabled", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" }) // what disableExternalServices() sets when EnableExternalServices=false
|
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
Expect(ag.AlbumImageAgents()).To(BeEmpty())
|
Expect(ag.AlbumImageAgents()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func (p *localAgent) AgentName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
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",
|
Sort: "playCount",
|
||||||
Order: "desc",
|
Order: "desc",
|
||||||
Max: count,
|
Max: count,
|
||||||
|
|
@ -43,7 +43,7 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||||
seed, err := p.ds.MediaFile().Get(ctx, id)
|
seed, err := p.ds.MediaFile(ctx).Get(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||||
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
persistence.SongGenres.ByID(genreIDs),
|
persistence.SongGenres.ByID(genreIDs),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ type SessionKeys struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||||
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
|
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||||
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
|
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||||
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
|
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
core/archiver.go
188
core/archiver.go
|
|
@ -2,32 +2,23 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
const archiveCoverArtSize = 500
|
|
||||||
|
|
||||||
type Archiver interface {
|
type Archiver interface {
|
||||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
|
|
@ -35,20 +26,18 @@ type Archiver interface {
|
||||||
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipPlaylist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
func NewArchiver(ms stream.MediaStreamer, ds model.DataStore, shares Share) Archiver {
|
||||||
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork}
|
return &archiver{ds: ds, ms: ms, shares: shares}
|
||||||
}
|
}
|
||||||
|
|
||||||
type archiver struct {
|
type archiver struct {
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
ms stream.MediaStreamer
|
ms stream.MediaStreamer
|
||||||
decider stream.TranscodeDecider
|
|
||||||
shares Share
|
shares Share
|
||||||
artwork artwork.Artwork
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{})
|
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
|
|
@ -58,30 +47,28 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr
|
||||||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
}
|
}
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
|
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rootArt, when set, is added to the archive root.
|
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
||||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
|
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
||||||
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
|
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
||||||
folders := albumFolders(albums)
|
return mf.AlbumID
|
||||||
|
})
|
||||||
for _, album := range albums {
|
for _, album := range albums {
|
||||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||||
isMultiDisc := len(discs) > 1
|
isMultiDisc := len(discs) > 1
|
||||||
folder := folders[album[0].AlbumID]
|
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
||||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
|
||||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||||
for _, mf := range album {
|
for _, mf := range album {
|
||||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
file := a.albumFilename(mf, format, isMultiDisc)
|
||||||
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
|
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
|
||||||
// Stop iterating: continuing would just rack up more
|
// Stop iterating: continuing would just rack up more
|
||||||
// rejections from the limiter. Close finalises whatever
|
// rejections from the limiter. Close finalises whatever
|
||||||
// tracks were already written; the rejected one is not
|
// tracks were already written; the rejected one is not
|
||||||
|
|
@ -91,10 +78,7 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||||
return addErr
|
return addErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
|
|
||||||
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
|
|
||||||
}
|
}
|
||||||
a.addCoverArtToZip(ctx, z, rootArt, "")
|
|
||||||
err = z.Close()
|
err = z.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||||
|
|
@ -112,61 +96,7 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
||||||
return z
|
return z
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tried in order; the first one whose values are distinct across the clashing albums wins.
|
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
||||||
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
|
|
||||||
var albumDisambiguators = []func(model.MediaFile) string{
|
|
||||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
|
|
||||||
func(mf model.MediaFile) string {
|
|
||||||
// Reissues share Year (often the original's) but not ReleaseYear.
|
|
||||||
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
|
|
||||||
return strconv.Itoa(y)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
},
|
|
||||||
func(mf model.MediaFile) string { return mf.MbzAlbumType },
|
|
||||||
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
|
|
||||||
func(mf model.MediaFile) string { return mf.CatalogNum },
|
|
||||||
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
|
|
||||||
func(mf model.MediaFile) string { return mf.AlbumID },
|
|
||||||
}
|
|
||||||
|
|
||||||
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
|
|
||||||
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
|
|
||||||
func albumFolders(albums [][]model.MediaFile) map[string]string {
|
|
||||||
byName := map[string][]model.MediaFile{}
|
|
||||||
for _, album := range albums {
|
|
||||||
name := str.SanitizeFilename(album[0].FullAlbumName())
|
|
||||||
byName[name] = append(byName[name], album[0])
|
|
||||||
}
|
|
||||||
folders := make(map[string]string, len(albums))
|
|
||||||
for name, group := range byName {
|
|
||||||
if len(group) == 1 {
|
|
||||||
folders[group[0].AlbumID] = name
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields:
|
|
||||||
for _, field := range albumDisambiguators {
|
|
||||||
ids := make(map[string]string, len(group)) // suffix -> album id
|
|
||||||
for _, mf := range group {
|
|
||||||
s := str.SanitizeFilename(field(mf))
|
|
||||||
if _, dup := ids[s]; dup {
|
|
||||||
continue fields
|
|
||||||
}
|
|
||||||
ids[s] = mf.AlbumID
|
|
||||||
}
|
|
||||||
for s, id := range ids {
|
|
||||||
folders[id] = name
|
|
||||||
if s != "" {
|
|
||||||
folders[id] = fmt.Sprintf("%s [%s]", name, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return folders
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string {
|
|
||||||
_, file := filepath.Split(mf.Path)
|
_, file := filepath.Split(mf.Path)
|
||||||
if format != "raw" {
|
if format != "raw" {
|
||||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||||
|
|
@ -174,7 +104,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
||||||
if isMultiDisc {
|
if isMultiDisc {
|
||||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s", folder, file)
|
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||||
|
|
@ -184,31 +114,27 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
|
||||||
return model.ErrNotAuthorized
|
return model.ErrNotAuthorized
|
||||||
}
|
}
|
||||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||||
// The share is the authorization (as in the public image handler): an anonymous lookup would
|
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
||||||
// hide a private playlist. Only the cover read is elevated.
|
|
||||||
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
|
|
||||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mfs := pls.MediaFiles()
|
mfs := pls.MediaFiles()
|
||||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true)
|
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error {
|
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
|
|
||||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||||
for idx, mf := range mfs {
|
for idx, mf := range mfs {
|
||||||
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
file := a.playlistFilename(mf, format, idx)
|
||||||
file := a.playlistFilename(mf, req.Format, idx)
|
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, req, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
|
||||||
// Abort the whole archive: continuing would silently emit
|
// Abort the whole archive: continuing would silently emit
|
||||||
// empty zip entries since the headers are already written.
|
// empty zip entries since the headers are already written.
|
||||||
_ = z.Close()
|
_ = z.Close()
|
||||||
|
|
@ -217,7 +143,6 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||||
mf.Path = file
|
mf.Path = file
|
||||||
zippedMfs[idx] = mf
|
zippedMfs[idx] = mf
|
||||||
}
|
}
|
||||||
a.addCoverArtToZip(coverCtx, z, coverArt, "")
|
|
||||||
|
|
||||||
// Add M3U file if requested
|
// Add M3U file if requested
|
||||||
if addM3U && len(zippedMfs) > 0 {
|
if addM3U && len(zippedMfs) > 0 {
|
||||||
|
|
@ -254,14 +179,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int)
|
||||||
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
|
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
||||||
if format == "" || format == "raw" {
|
|
||||||
return stream.Request{Format: "raw"}
|
|
||||||
}
|
|
||||||
return a.decider.ResolveRequest(ctx, mf, format, bitrate, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, req stream.Request, filename string) error {
|
|
||||||
path := mf.AbsolutePath()
|
path := mf.AbsolutePath()
|
||||||
|
|
||||||
// Open the source before writing the zip entry header so a rejection
|
// Open the source before writing the zip entry header so a rejection
|
||||||
|
|
@ -269,13 +187,13 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||||
// archive.
|
// archive.
|
||||||
var r io.ReadCloser
|
var r io.ReadCloser
|
||||||
var err error
|
var err error
|
||||||
if req.Format != "raw" {
|
if format != "raw" && format != "" {
|
||||||
r, err = a.ms.NewStream(ctx, &mf, req)
|
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
||||||
} else {
|
} else {
|
||||||
r, err = os.Open(path)
|
r, err = os.Open(path)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
|
log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -302,61 +220,3 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
|
|
||||||
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
|
|
||||||
if artID.ID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Buffered so a failed read leaves no empty entry.
|
|
||||||
data, err := a.readCoverArt(ctx, artID)
|
|
||||||
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
|
|
||||||
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ext := coverArtExtension(data)
|
|
||||||
if ext == "" {
|
|
||||||
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w, err := z.CreateHeader(&zip.FileHeader{
|
|
||||||
Name: path.Join(dir, "folder."+ext),
|
|
||||||
Modified: time.Now(),
|
|
||||||
Method: zip.Store,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err = w.Write(data); err != nil {
|
|
||||||
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
|
|
||||||
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer img.Close()
|
|
||||||
return io.ReadAll(img)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resizing may re-encode the image, so the type comes from its bytes.
|
|
||||||
func coverArtExtension(data []byte) string {
|
|
||||||
switch http.DetectContentType(data) {
|
|
||||||
case "image/jpeg":
|
|
||||||
return "jpg"
|
|
||||||
case "image/png":
|
|
||||||
return "png"
|
|
||||||
case "image/webp":
|
|
||||||
return "webp"
|
|
||||||
case "image/gif":
|
|
||||||
return "gif"
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,13 @@ import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -25,19 +21,15 @@ var _ = Describe("Archiver", func() {
|
||||||
var (
|
var (
|
||||||
arch core.Archiver
|
arch core.Archiver
|
||||||
ms *mockMediaStreamer
|
ms *mockMediaStreamer
|
||||||
dc *fakeDecider
|
|
||||||
ds *mockDataStore
|
ds *mockDataStore
|
||||||
sh *mockShare
|
sh *mockShare
|
||||||
ca *mockCoverArt
|
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ms = &mockMediaStreamer{}
|
ms = &mockMediaStreamer{}
|
||||||
dc = &fakeDecider{}
|
|
||||||
sh = &mockShare{}
|
sh = &mockShare{}
|
||||||
ds = &mockDataStore{}
|
ds = &mockDataStore{}
|
||||||
ca = &mockCoverArt{images: map[string][]byte{}}
|
arch = core.NewArchiver(ms, ds, sh)
|
||||||
arch = core.NewArchiver(ms, dc, ds, sh, ca)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipAlbum", func() {
|
Context("ZipAlbum", func() {
|
||||||
|
|
@ -53,7 +45,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -67,23 +59,6 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
Expect(zr.File[0].Name).To(Equal("Album_Promo/01 - track1.mp3"))
|
||||||
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
Expect(zr.File[1].Name).To(Equal("Album_Promo/02 - track2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("streams the request resolved by the transcode decider and names the entry after its format", func() {
|
|
||||||
mfRepo := &mockMediaFileRepository{}
|
|
||||||
mfRepo.On("GetAll", mock.Anything).Return(model.MediaFiles{{Path: "test_data/01 - track1.flac", Suffix: "flac", AlbumID: "1"}}, nil)
|
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
|
||||||
resolved := stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}
|
|
||||||
dc.resolved = &resolved
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, resolved).Return(io.NopCloser(strings.NewReader("test")), nil).Once()
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipAlbum(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
ms.AssertExpectations(GinkgoT())
|
|
||||||
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(zr.File[0].Name).To(HaveSuffix("01 - track1.opus"))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipArtist", func() {
|
Context("ZipArtist", func() {
|
||||||
|
|
@ -102,7 +77,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -116,137 +91,6 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
When("albums that share a name", func() {
|
|
||||||
|
|
||||||
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
|
|
||||||
zipArtistEntries := func(mfs model.MediaFiles) []string {
|
|
||||||
mfRepo := &mockMediaFileRepository{}
|
|
||||||
mfRepo.On("GetAll", mock.Anything).Return(mfs, nil)
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).To(BeNil())
|
|
||||||
names := make([]string, len(zr.File))
|
|
||||||
for i, f := range zr.File {
|
|
||||||
names[i] = f.Name
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
It("keeps the albums in query order", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
|
||||||
{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("suffixes the year when it tells the albums apart", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("prefers the release year, so reissues of the same original are told apart", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
|
|
||||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("names the folder after the full album name", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("prefers the album version over the year when it is not part of the name", func() {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Subsonic.AppendAlbumVersion = false })
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves the one album without the field unsuffixed", func() {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Subsonic.AppendAlbumVersion = false })
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("skips a field that is empty on more than one album", func() {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Subsonic.AppendAlbumVersion = false })
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
|
||||||
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
|
|
||||||
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("skips a field that is the same on every album", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to the album id when nothing differs", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("treats names that sanitize to the same folder as a clash", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sanitizes the suffix", func() {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Subsonic.AppendAlbumVersion = false })
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves the folder name alone when only one album has it", func() {
|
|
||||||
names := zipArtistEntries(model.MediaFiles{
|
|
||||||
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
|
||||||
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
|
|
||||||
})
|
|
||||||
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("when the transcode limiter rejects a file", func() {
|
Context("when the transcode limiter rejects a file", func() {
|
||||||
|
|
@ -261,7 +105,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Filters: squirrel.Eq{"album_id": "1"},
|
Filters: squirrel.Eq{"album_id": "1"},
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
ds.On("MediaFile").Return(mfRepo)
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||||
|
|
@ -311,30 +155,6 @@ var _ = Describe("Archiver", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipPlaylist", func() {
|
Context("ZipPlaylist", func() {
|
||||||
It("names the entries and the M3U lines after the resolved format", func() {
|
|
||||||
pls := &model.Playlist{ID: "1", Name: "Test Playlist", Tracks: []model.PlaylistTrack{
|
|
||||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.flac", Suffix: "flac", Artist: "Artist 1", Title: "track1"}},
|
|
||||||
}}
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
|
||||||
ds.On("Playlist").Return(plRepo)
|
|
||||||
dc.resolved = &stream.Request{Format: "opus", BitRate: 128}
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, *dc.resolved).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipPlaylist(GinkgoT().Context(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(zr.File[0].Name).To(Equal("01 - Artist 1 - track1.opus"))
|
|
||||||
m3u, err := zr.File[1].Open()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
defer m3u.Close()
|
|
||||||
content, err := io.ReadAll(m3u)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(string(content)).To(ContainSubstring("01 - Artist 1 - track1.opus"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("zips a playlist correctly", func() {
|
It("zips a playlist correctly", func() {
|
||||||
tracks := []model.PlaylistTrack{
|
tracks := []model.PlaylistTrack{
|
||||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1, Artist: "AC/DC", Title: "track1"}},
|
||||||
|
|
@ -349,7 +169,7 @@ var _ = Describe("Archiver", func() {
|
||||||
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
plRepo := &mockPlaylistRepository{}
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||||
ds.On("Playlist").Return(plRepo)
|
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -376,195 +196,24 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
Context("cover art", func() {
|
|
||||||
var (
|
|
||||||
jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg")
|
|
||||||
pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png")
|
|
||||||
)
|
|
||||||
|
|
||||||
mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) {
|
|
||||||
mfRepo := &mockMediaFileRepository{}
|
|
||||||
mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil)
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
It("adds the album cover to the album folder", func() {
|
|
||||||
ca.images["al-1"] = jpegData
|
|
||||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(2))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData))
|
|
||||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds the artist image to the root and each album cover to its folder", func() {
|
|
||||||
ca.images["ar-1"] = pngData
|
|
||||||
ca.images["al-1"] = jpegData
|
|
||||||
ca.images["al-2"] = jpegData
|
|
||||||
mockAlbumTracks(squirrel.And{
|
|
||||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
|
||||||
squirrel.Eq{"missing": false},
|
|
||||||
}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
|
||||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(5))
|
|
||||||
Expect(files).To(HaveKeyWithValue("folder.png", pngData))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("puts each same-named album's cover in that album's own folder", func() {
|
|
||||||
ca.images["al-1"] = jpegData
|
|
||||||
ca.images["al-2"] = pngData
|
|
||||||
mockAlbumTracks(squirrel.And{
|
|
||||||
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
|
||||||
squirrel.Eq{"missing": false},
|
|
||||||
}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1},
|
|
||||||
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData))
|
|
||||||
Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds the playlist cover to the root", func() {
|
|
||||||
ca.images["pl-1"] = jpegData
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{
|
|
||||||
ID: "1",
|
|
||||||
Name: "Test Playlist",
|
|
||||||
Tracks: []model.PlaylistTrack{
|
|
||||||
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}},
|
|
||||||
},
|
|
||||||
}, nil)
|
|
||||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(3))
|
|
||||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
|
||||||
Expect(files).To(HaveKey("Test Playlist.m3u"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("adds the shared item's cover to the root, even for a private playlist", func() {
|
|
||||||
ca.images["pl-10"] = jpegData
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
|
||||||
share := &model.Share{
|
|
||||||
ID: "1",
|
|
||||||
Downloadable: true,
|
|
||||||
Format: "mp3",
|
|
||||||
MaxBitRate: 128,
|
|
||||||
ResourceType: "playlist",
|
|
||||||
ResourceIDs: "10",
|
|
||||||
Tracks: model.MediaFiles{
|
|
||||||
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(2))
|
|
||||||
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
|
||||||
Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("still builds the archive when the cover cannot be read", func() {
|
|
||||||
ca.err = errors.New("boom")
|
|
||||||
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
|
||||||
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
|
||||||
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
|
||||||
|
|
||||||
files := readZip(out)
|
|
||||||
Expect(files).To(HaveLen(1))
|
|
||||||
Expect(files).To(HaveKey("Album/01 - track1.mp3"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
func readZip(out *bytes.Buffer) map[string][]byte {
|
|
||||||
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
files := make(map[string][]byte, len(zr.File))
|
|
||||||
for _, f := range zr.File {
|
|
||||||
r, err := f.Open()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
data, err := io.ReadAll(r)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
_ = r.Close()
|
|
||||||
files[f.Name] = data
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
type coverRequest struct {
|
|
||||||
id string
|
|
||||||
size int
|
|
||||||
square bool
|
|
||||||
admin bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockCoverArt struct {
|
|
||||||
artwork.Artwork
|
|
||||||
images map[string][]byte
|
|
||||||
err error
|
|
||||||
requests []coverRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) {
|
|
||||||
user, _ := request.UserFrom(ctx)
|
|
||||||
m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin})
|
|
||||||
if m.err != nil {
|
|
||||||
return nil, m.err
|
|
||||||
}
|
|
||||||
data, ok := m.images[artID.String()]
|
|
||||||
if !ok {
|
|
||||||
return nil, artwork.ErrUnavailable
|
|
||||||
}
|
|
||||||
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockDataStore struct {
|
type mockDataStore struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
model.DataStore
|
model.DataStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
|
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
||||||
args := m.Called()
|
args := m.Called(ctx)
|
||||||
return args.Get(0).(model.MediaFileRepository)
|
return args.Get(0).(model.MediaFileRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Playlist() model.PlaylistRepository {
|
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
||||||
args := m.Called()
|
args := m.Called(ctx)
|
||||||
return args.Get(0).(model.PlaylistRepository)
|
return args.Get(0).(model.PlaylistRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Library() model.LibraryRepository {
|
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
||||||
return &mockLibraryRepository{}
|
return &mockLibraryRepository{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -573,7 +222,7 @@ type mockLibraryRepository struct {
|
||||||
model.LibraryRepository
|
model.LibraryRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
|
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
||||||
return "/music", nil
|
return "/music", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -582,7 +231,7 @@ type mockMediaFileRepository struct {
|
||||||
model.MediaFileRepository
|
model.MediaFileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
args := m.Called(options)
|
args := m.Called(options)
|
||||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -592,7 +241,7 @@ type mockPlaylistRepository struct {
|
||||||
model.PlaylistRepository
|
model.PlaylistRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||||
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
||||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -610,19 +259,6 @@ func (m *mockMediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile,
|
||||||
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
return &stream.Stream{ReadCloser: args.Get(0).(io.ReadCloser)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fakeDecider echoes the legacy format/bitrate unless a resolved request is set.
|
|
||||||
type fakeDecider struct {
|
|
||||||
stream.TranscodeDecider
|
|
||||||
resolved *stream.Request
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeDecider) ResolveRequest(_ context.Context, _ *model.MediaFile, format string, bitRate int, offset int) stream.Request {
|
|
||||||
if f.resolved != nil {
|
|
||||||
return *f.resolved
|
|
||||||
}
|
|
||||||
return stream.Request{Format: format, BitRate: bitRate, Offset: offset}
|
|
||||||
}
|
|
||||||
|
|
||||||
type mockShare struct {
|
type mockShare struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
core.Share
|
core.Share
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
|
|
||||||
// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
|
// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
|
||||||
func externalName(name string) string {
|
func externalName(name string) string {
|
||||||
if conf.Server().DevPreserveUnicodeInExternalCalls {
|
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return str.Clear(name)
|
return str.Clear(name)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -64,7 +65,7 @@ func imageAgents(fakes ...*fakeImageAgent) *agents.Agents {
|
||||||
agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
|
agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
|
||||||
names = append(names, fake.name)
|
names = append(names, fake.name)
|
||||||
}
|
}
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = strings.Join(names, ",") })
|
conf.Server.Agents = strings.Join(names, ",")
|
||||||
return agents.GetAgents(&tests.MockDataStore{}, nil)
|
return agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +76,7 @@ var _ = Describe("agent images", func() {
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte("image-bytes"))
|
_, _ = w.Write([]byte("image-bytes"))
|
||||||
|
|
@ -198,7 +200,7 @@ var _ = Describe("agent images", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears typographic characters from the query name unless preserving unicode", func() {
|
It("clears typographic characters from the query name unless preserving unicode", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevPreserveUnicodeInExternalCalls = false })
|
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||||
a := &fakeImageAgent{name: "agentA"}
|
a := &fakeImageAgent{name: "agentA"}
|
||||||
ag := imageAgents(a)
|
ag := imageAgents(a)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
|
||||||
var err error
|
var err error
|
||||||
switch artID.Kind {
|
switch artID.Kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
found, err = ds.Artist().Exists(ctx, artID.ID)
|
found, err = ds.Artist(ctx).Exists(artID.ID)
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
found, err = ds.Album().Exists(ctx, artID.ID)
|
found, err = ds.Album(ctx).Exists(artID.ID)
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
found, err = ds.MediaFile().Exists(ctx, artID.ID)
|
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
found, err = ds.Playlist().Exists(ctx, artID.ID)
|
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
||||||
case model.KindRadioArtwork:
|
case model.KindRadioArtwork:
|
||||||
found, err = ds.Radio().Exists(ctx, artID.ID)
|
found, err = ds.Radio(ctx).Exists(artID.ID)
|
||||||
case model.KindDiscArtwork:
|
case model.KindDiscArtwork:
|
||||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
found, err = ds.Album().Exists(ctx, albumID)
|
found, err = ds.Album(ctx).Exists(albumID)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
|
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, model.ErrNotFound):
|
case errors.Is(err, model.ErrNotFound):
|
||||||
return s.provisional(ctx, artID, size, square)
|
return s.provisional(ctx, artID, size, square)
|
||||||
|
|
@ -168,12 +168,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
|
||||||
if !entityExists(ctx, s.ds, artID) {
|
if !entityExists(ctx, s.ds, artID) {
|
||||||
return nil, ErrUnavailable
|
return nil, ErrUnavailable
|
||||||
}
|
}
|
||||||
// Checked here, not in openOriginal: a resize-cache hit never opens the source.
|
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
||||||
if isFileBacked(ia.Source) && !model.IsImageFile(ia.SourcePath) {
|
|
||||||
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
|
||||||
return s.dangling(ctx, artID)
|
|
||||||
}
|
|
||||||
art, err := s.ds.Artwork().GetImage(ctx, ia.Hash)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return s.dangling(ctx, artID)
|
return s.dangling(ctx, artID)
|
||||||
|
|
@ -263,14 +258,14 @@ func (s *service) serveResolution(ctx context.Context, res resolution, size int,
|
||||||
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||||
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
// The setting is not in the config fingerprint, so honor it at serve time: a direct mf- URL
|
||||||
// must fall back to disc/album instead of serving stale persisted embedded art.
|
// must fall back to disc/album instead of serving stale persisted embedded art.
|
||||||
if !conf.Server().EnableMediaFileCoverArt {
|
if !conf.Server.EnableMediaFileCoverArt {
|
||||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||||
}
|
}
|
||||||
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && ia.Hash != "":
|
case err == nil && ia.Hash != "":
|
||||||
return s.serveHash(ctx, artID, ia, size, square)
|
return s.serveHash(ctx, artID, ia, size, square)
|
||||||
|
|
@ -283,11 +278,11 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
||||||
}
|
}
|
||||||
noRow := errors.Is(err, model.ErrNotFound)
|
noRow := errors.Is(err, model.ErrNotFound)
|
||||||
|
|
||||||
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if noRow && conf.Server().EnableMediaFileCoverArt && mf.HasCoverArt {
|
if noRow && conf.Server.EnableMediaFileCoverArt && mf.HasCoverArt {
|
||||||
return s.provisionalEmbedded(ctx, artID, *mf, size, square)
|
return s.provisionalEmbedded(ctx, artID, *mf, size, square)
|
||||||
}
|
}
|
||||||
// Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
|
// Mirror MediaFile.CoverArtID: a track defers to its disc art, which falls back to the album.
|
||||||
|
|
@ -317,13 +312,13 @@ func (s *service) serveDisc(ctx context.Context, artID model.ArtworkID, size int
|
||||||
}
|
}
|
||||||
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
|
// Single-disc albums run the chain too: a disc can carry art distinct from the album cover.
|
||||||
selectImage := func() (io.ReadCloser, error) {
|
selectImage := func() (io.ReadCloser, error) {
|
||||||
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server().DiscArtPriority, &chainState{})
|
res, err := dr.selectImage(ctx, s.ffmpeg, conf.Server.DiscArtPriority, &chainState{})
|
||||||
return res.reader, err
|
return res.reader, err
|
||||||
}
|
}
|
||||||
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
albumArtID := model.ArtworkID{Kind: model.KindAlbumArtwork, ID: dr.album.ID}
|
||||||
// Disc art has no state row, hence no content hash: keying on id, album mtime and
|
// Disc art has no state row, hence no content hash: keying on id, album mtime and
|
||||||
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
|
// DiscArtPriority lets a warm cache answer without running the chain or touching the disk.
|
||||||
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server().DiscArtPriority)
|
key := fmt.Sprintf("%s|%d|%s", artID.ID, dr.cacheTime().UnixNano(), conf.Server.DiscArtPriority)
|
||||||
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
|
img, err := s.serveSource(ctx, key, "", dr.cacheTime(), size, square, selectImage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, context.Canceled) {
|
if errors.Is(err, context.Canceled) {
|
||||||
|
|
@ -342,7 +337,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||||
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||||
ItemKind: artID.Kind.Prefix(),
|
ItemKind: artID.Kind.Prefix(),
|
||||||
ItemID: artID.ID,
|
ItemID: artID.ID,
|
||||||
ImageType: model.ImageTypePrimary,
|
ImageType: model.ImageTypePrimary,
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,19 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/metadata"
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
"github.com/navidrome/navidrome/utils/httpclient"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
"go.uber.org/goleak"
|
"go.uber.org/goleak"
|
||||||
|
|
@ -42,16 +37,6 @@ func TestArtwork(t *testing.T) {
|
||||||
RunSpecs(t, "Artwork Suite")
|
RunSpecs(t, "Artwork Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
||||||
// productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
|
|
||||||
var productionImageClient = remoteImageClient
|
|
||||||
|
|
||||||
// httptest servers listen on loopback, which the production client refuses.
|
|
||||||
var _ = BeforeSuite(func() {
|
|
||||||
remoteImageClient = httpclient.NewExternal(5*time.Second, netip.MustParsePrefix("127.0.0.0/8"), netip.MustParsePrefix("::1/128"))
|
|
||||||
})
|
|
||||||
|
|
||||||
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests.
|
||||||
type osDirFS struct{ fs.FS }
|
type osDirFS struct{ fs.FS }
|
||||||
|
|
||||||
|
|
@ -112,15 +97,15 @@ type fakeFolderRepo struct {
|
||||||
otherAudioErr error
|
otherAudioErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||||
return f.hasOtherAudio, f.otherAudioErr
|
return f.hasOtherAudio, f.otherAudioErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
||||||
f.getCallCount++
|
f.getCallCount++
|
||||||
if f.getErr != nil {
|
if f.getErr != nil {
|
||||||
return nil, f.getErr
|
return nil, f.getErr
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/resources"
|
"github.com/navidrome/navidrome/resources"
|
||||||
|
|
@ -44,8 +45,8 @@ var _ = Describe("Artwork", func() {
|
||||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||||
seedEntity(kind, id)
|
seedEntity(kind, id)
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
@ -55,9 +56,9 @@ var _ = Describe("Artwork", func() {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
switch kind {
|
switch kind {
|
||||||
case "al":
|
case "al":
|
||||||
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
|
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||||
case "mf":
|
case "mf":
|
||||||
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
|
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,6 +71,7 @@ var _ = Describe("Artwork", func() {
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
var err error
|
var err error
|
||||||
repoRoot, err = os.Getwd()
|
repoRoot, err = os.Getwd()
|
||||||
|
|
@ -77,13 +79,11 @@ var _ = Describe("Artwork", func() {
|
||||||
coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
coverBytes, err = os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.EnableWebPEncoding = false
|
||||||
c.EnableWebPEncoding = false
|
conf.Server.CoverArtQuality = 75
|
||||||
c.CoverArtQuality = 75
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
c.CoverArtPriority = "cover.*"
|
conf.Server.DiscArtPriority = "cover.*"
|
||||||
c.DiscArtPriority = "cover.*"
|
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
||||||
c.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
|
||||||
})
|
|
||||||
|
|
||||||
artRepo = tests.CreateMockArtworkRepo()
|
artRepo = tests.CreateMockArtworkRepo()
|
||||||
queueRepo = tests.CreateMockArtworkQueueRepo()
|
queueRepo = tests.CreateMockArtworkQueueRepo()
|
||||||
|
|
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
mtime := fileMtime(imgPath)
|
mtime := fileMtime(imgPath)
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al2")
|
seedEntity("al", "al2")
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -159,60 +159,13 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(readAll(img)).To(Equal(coverBytes))
|
Expect(readAll(img)).To(Equal(coverBytes))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("treats a file-backed row pointing at a non-image file as dangling", func() {
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
|
||||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
|
||||||
seedEntity("al", "alni")
|
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
|
||||||
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
|
||||||
})).To(Succeed())
|
|
||||||
|
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-alni"), 0, false)
|
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "alni")].Priority).To(Equal(model.ArtworkPriorityScan))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("refuses a non-image file-backed row even when a resized copy is already cached", func() {
|
|
||||||
secret := []byte("password=secret")
|
|
||||||
dir := GinkgoT().TempDir()
|
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
|
||||||
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
|
||||||
seedEntity("al", "alnic")
|
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
|
||||||
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
|
||||||
})).To(Succeed())
|
|
||||||
|
|
||||||
// Older versions cached the raw bytes when the resize failed.
|
|
||||||
seed := func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(secret)), nil }
|
|
||||||
stream, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, open: seed, ffmpeg: ffm})
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(io.ReadAll(stream)).To(Equal(secret))
|
|
||||||
Expect(stream.Close()).To(Succeed())
|
|
||||||
Eventually(func(g Gomega) {
|
|
||||||
s, err := imgCache.Get(ctx, &resizedItem{hash: "eeeeeeeeeeeeeeee", size: 100, ffmpeg: ffm,
|
|
||||||
open: func() (io.ReadCloser, error) { return nil, os.ErrNotExist }})
|
|
||||||
g.Expect(err).ToNot(HaveOccurred())
|
|
||||||
g.Expect(s.Cached).To(BeTrue())
|
|
||||||
_ = s.Close()
|
|
||||||
}).Should(Succeed())
|
|
||||||
|
|
||||||
_, err = svc.Get(ctx, model.MustParseArtworkID("al-alnic"), 100, false)
|
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "alnic")].Priority).To(Equal(model.ArtworkPriorityScan))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
It("treats a full-size mtime mismatch as dangling: unavailable, re-enqueued at Scan, state untouched", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3")
|
seedEntity("al", "al3")
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -220,7 +173,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||||
})
|
})
|
||||||
|
|
@ -229,9 +182,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3b")
|
seedEntity("al", "al3b")
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -252,7 +205,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("never re-enqueues an absent state on view, however old", func() {
|
It("never re-enqueues an absent state on view, however old", func() {
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -272,7 +225,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(readAll(img)).To(Equal(coverBytes))
|
Expect(readAll(img)).To(Equal(coverBytes))
|
||||||
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -283,7 +236,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -298,7 +251,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() {
|
It("ignores a resolved mf row and delegates to the album when per-track art is disabled", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = false })
|
conf.Server.EnableMediaFileCoverArt = false
|
||||||
seedFoundStore("mf", "mf7", []byte("stale embedded track art"))
|
seedFoundStore("mf", "mf7", []byte("stale embedded track art"))
|
||||||
seedFoundStore("al", "albz", coverBytes)
|
seedFoundStore("al", "albz", coverBytes)
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}})
|
mfRepo.SetData(model.MediaFiles{{ID: "mf7", AlbumID: "albz"}})
|
||||||
|
|
@ -310,7 +263,7 @@ var _ = Describe("Artwork", func() {
|
||||||
|
|
||||||
It("delegates to the album when the track's state is absent", func() {
|
It("delegates to the album when the track's state is absent", func() {
|
||||||
seedFoundStore("al", "albm", coverBytes)
|
seedFoundStore("al", "albm", coverBytes)
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||||
|
|
@ -321,7 +274,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() {
|
It("delegates to the album (no enqueue) when the track is not embedded-eligible", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = true })
|
conf.Server.EnableMediaFileCoverArt = true
|
||||||
seedFoundStore("al", "albn", coverBytes)
|
seedFoundStore("al", "albn", coverBytes)
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}})
|
mfRepo.SetData(model.MediaFiles{{ID: "mf3", AlbumID: "albn", HasCoverArt: false}})
|
||||||
|
|
||||||
|
|
@ -333,7 +286,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("extracts embedded art provisionally and enqueues the track when eligible", func() {
|
It("extracts embedded art provisionally and enqueues the track when eligible", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = true })
|
conf.Server.EnableMediaFileCoverArt = true
|
||||||
mfRepo.SetData(model.MediaFiles{{
|
mfRepo.SetData(model.MediaFiles{{
|
||||||
ID: "mf4", AlbumID: "albo", HasCoverArt: true,
|
ID: "mf4", AlbumID: "albo", HasCoverArt: true,
|
||||||
Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0,
|
Path: "tests/fixtures/artist/an-album/test.mp3", LibraryID: 0,
|
||||||
|
|
@ -343,7 +296,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -360,7 +313,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to the album when an eligible track's embedded art will not extract", func() {
|
It("falls back to the album when an eligible track's embedded art will not extract", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = true })
|
conf.Server.EnableMediaFileCoverArt = true
|
||||||
// HasCoverArt is set, but the file is not audio, so nothing extracts.
|
// HasCoverArt is set, but the file is not audio, so nothing extracts.
|
||||||
mfRepo.SetData(model.MediaFiles{{
|
mfRepo.SetData(model.MediaFiles{{
|
||||||
ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true,
|
ID: "mfbad", AlbumID: "albad", LibraryID: 0, HasCoverArt: true,
|
||||||
|
|
@ -494,7 +447,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to the artist placeholder for an absent artist", func() {
|
It("falls back to the artist placeholder for an absent artist", func() {
|
||||||
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||||
|
|
||||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -535,7 +488,7 @@ var _ = Describe("EntityExists", func() {
|
||||||
artistRepo := tests.CreateMockArtistRepo()
|
artistRepo := tests.CreateMockArtistRepo()
|
||||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||||
radioRepo := tests.CreateMockedRadioRepo()
|
radioRepo := tests.CreateMockedRadioRepo()
|
||||||
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func BenchmarkResizeFullPipeline(b *testing.B) {
|
func BenchmarkResizeFullPipeline(b *testing.B) {
|
||||||
b.Cleanup(conf.SnapshotConfig())
|
cleanup := configtest.SetupConfig()
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtQuality = 75 })
|
b.Cleanup(cleanup)
|
||||||
|
conf.Server.CoverArtQuality = 75
|
||||||
|
|
||||||
sourceSizes := []int{1000, 3000}
|
sourceSizes := []int{1000, 3000}
|
||||||
targetSize := 300
|
targetSize := 300
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
al, err := ds.Album().Get(ctx, albumID)
|
al, err := ds.Album(ctx).Get(albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query mediafiles for this album + disc to find folder associations and first track
|
// Query mediafiles for this album + disc to find folder associations and first track
|
||||||
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
||||||
Sort: "track_number",
|
Sort: "track_number",
|
||||||
Order: "ASC",
|
Order: "ASC",
|
||||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||||
|
|
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
// Resolve folder IDs to library-relative paths
|
// Resolve folder IDs to library-relative paths
|
||||||
discFoldersRel := make(map[string]bool)
|
discFoldersRel := make(map[string]bool)
|
||||||
if len(folderIDs) > 0 {
|
if len(folderIDs) > 0 {
|
||||||
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
|
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
|
|
@ -43,39 +44,38 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
itemFound := func(kind model.Kind, id string) func() bool {
|
itemFound := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash == ""
|
return err == nil && ia.Hash == ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||||
bump := func(kind, id string) {
|
bump := func(kind, id string) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
||||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBump,
|
Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
repoRoot, err := os.Getwd()
|
repoRoot, err := os.Getwd()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
coverBytes = readFixture(coverFixture)
|
coverBytes = readFixture(coverFixture)
|
||||||
|
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
||||||
c.CacheFolder = conf.NewDir(GinkgoT().TempDir())
|
conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
||||||
c.DataFolder = conf.NewDir(GinkgoT().TempDir())
|
conf.Server.CoverArtPriority = "cover.jpg"
|
||||||
c.CoverArtPriority = "cover.jpg"
|
conf.Server.ArtistArtPriority = "artist.png" // keeps artist resolution offline
|
||||||
c.ArtistArtPriority = "artist.png" // keeps artist resolution offline
|
conf.Server.EnableMediaFileCoverArt = true
|
||||||
c.EnableMediaFileCoverArt = true
|
conf.Server.DevArtworkWorkerConcurrency = 1
|
||||||
c.DevArtworkWorkerConcurrency = 1
|
|
||||||
})
|
|
||||||
|
|
||||||
folderRepo = &fakeFolderRepo{}
|
folderRepo = &fakeFolderRepo{}
|
||||||
libRepo = &tests.MockLibraryRepo{}
|
libRepo = &tests.MockLibraryRepo{}
|
||||||
|
|
@ -122,7 +122,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
|
Expect(os.MkdirAll(filepath.Join(libDir, "an-album"), 0755)).To(Succeed())
|
||||||
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
|
Expect(os.WriteFile(filepath.Join(libDir, "an-album", "cover.jxl"), jxlFixture, 0600)).To(Succeed())
|
||||||
|
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
|
libRepo.SetData(model.Libraries{{ID: 0, Path: libDir}})
|
||||||
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
|
folderRepo.result = []model.Folder{{Path: "an-album", ImageFiles: []string{"cover.jxl"}}}
|
||||||
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
albumRepo.SetData(model.Albums{{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}, LibraryID: 0}})
|
||||||
|
|
@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ar", "ar1")
|
bump("ar", "ar1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("pl", "pl1")
|
bump("pl", "pl1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(img.Hash).To(Equal(ia.Hash))
|
Expect(img.Hash).To(Equal(ia.Hash))
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/png"))
|
Expect(art.Mime).To(Equal("image/png"))
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
provisionalBytes := readAll(provisional)
|
provisionalBytes := readAll(provisional)
|
||||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||||
|
|
||||||
// The provisional read enqueued a Bump; drain it.
|
// The provisional read enqueued a Bump; drain it.
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||||
|
|
@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||||
Expect(art.Width).To(BeNumerically(">", 0))
|
Expect(art.Width).To(BeNumerically(">", 0))
|
||||||
|
|
@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ctx, ia.Hash)
|
art, err := artRepo.GetImage(ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/gif"))
|
Expect(art.Mime).To(Equal("image/gif"))
|
||||||
Expect(art.Width).To(BeNumerically("==", 4))
|
Expect(art.Width).To(BeNumerically("==", 4))
|
||||||
|
|
@ -279,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||||
})
|
})
|
||||||
|
|
||||||
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
||||||
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||||
|
|
@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
staleHash := ia.Hash
|
staleHash := ia.Hash
|
||||||
|
|
||||||
|
|
@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
// That failed read enqueued a re-resolution.
|
// That failed read enqueued a re-resolution.
|
||||||
runWorkerUntil(ctx, worker, func() bool {
|
runWorkerUntil(ctx, worker, func() bool {
|
||||||
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||||
})
|
})
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← matched by cover.*
|
// └── cover.jpg ← matched by cover.*
|
||||||
It("returns the album-root cover", func() {
|
It("returns the album-root cover", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||||
|
|
@ -46,7 +46,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── cover.jpg
|
// │ └── cover.jpg
|
||||||
// └── cover.jpg ← should win (album-root fallback)
|
// └── cover.jpg ← should win (album-root fallback)
|
||||||
It("prefers the album-root cover over per-disc covers", func() {
|
It("prefers the album-root cover over per-disc covers", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
|
@ -77,7 +77,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── folder.jpg
|
// │ └── folder.jpg
|
||||||
// └── folder.jpg ← should win (album-root fallback)
|
// └── folder.jpg ← should win (album-root fallback)
|
||||||
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── 01 - Track.mp3
|
// │ └── 01 - Track.mp3
|
||||||
// └── cover.jpg ← should win (parent-folder fallback)
|
// └── cover.jpg ← should win (parent-folder fallback)
|
||||||
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||||
|
|
@ -120,7 +120,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── folder.jpg
|
// │ └── folder.jpg
|
||||||
// └── cover.jpg ← should win (album-root)
|
// └── cover.jpg ← should win (album-root)
|
||||||
It("prefers the album-root cover.jpg", func() {
|
It("prefers the album-root cover.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
|
@ -139,7 +139,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("returns the embedded image", func() {
|
It("returns the embedded image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" })
|
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
"Artist/Album/cover.jpg": smallPNG("external"),
|
"Artist/Album/cover.jpg": smallPNG("external"),
|
||||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
||||||
It("falls through to embedded artwork", func() {
|
It("falls through to embedded artwork", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external, embedded" })
|
conf.Server.CoverArtPriority = "external, embedded"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
})
|
})
|
||||||
|
|
@ -177,7 +177,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── Cover.JPG ← matched case-insensitively by cover.*
|
// └── Cover.JPG ← matched case-insensitively by cover.*
|
||||||
It("matches case-insensitively against cover.*", func() {
|
It("matches case-insensitively against cover.*", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*, folder.*" })
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
|
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
|
||||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── cover.jpg ← wins (no numeric suffix)
|
// ├── cover.jpg ← wins (no numeric suffix)
|
||||||
// └── cover.1.jpg
|
// └── cover.1.jpg
|
||||||
It("prefers the file without a numeric suffix", func() {
|
It("prefers the file without a numeric suffix", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||||
|
|
@ -210,7 +210,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3 (no image files — settles absent)
|
// └── 01 - Track.mp3 (no image files — settles absent)
|
||||||
It("settles absent", func() {
|
It("settles absent", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*, folder.*" })
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
})
|
})
|
||||||
|
|
@ -228,7 +228,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── folder.jpg ← matched by folder.*
|
// └── folder.jpg ← matched by folder.*
|
||||||
It("falls through to folder.jpg", func() {
|
It("falls through to folder.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||||
|
|
@ -244,7 +244,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── front.jpg ← matched by front.*
|
// └── front.jpg ← matched by front.*
|
||||||
It("falls through to front.jpg", func() {
|
It("falls through to front.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/front.jpg": smallPNG("front"),
|
"Artist/Album/front.jpg": smallPNG("front"),
|
||||||
|
|
@ -262,7 +262,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── folder.jpg
|
// ├── folder.jpg
|
||||||
// └── front.jpg
|
// └── front.jpg
|
||||||
It("prefers cover.* (first in CoverArtPriority)", func() {
|
It("prefers cover.* (first in CoverArtPriority)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
@ -281,7 +281,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
||||||
// └── front.jpg
|
// └── front.jpg
|
||||||
It("prefers folder.* over front.*", func() {
|
It("prefers folder.* over front.*", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||||
|
|
@ -300,7 +300,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── cover.1.jpg
|
// ├── cover.1.jpg
|
||||||
// └── cover.2.jpg
|
// └── cover.2.jpg
|
||||||
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.2.jpg": smallPNG("second"),
|
"Artist/Album/cover.2.jpg": smallPNG("second"),
|
||||||
|
|
@ -318,7 +318,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
||||||
It("skips the unknown pattern and falls through to the matching one", func() {
|
It("skips the unknown pattern and falls through to the matching one", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "bogus.*, cover.*" })
|
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
@ -340,7 +340,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("does not use the artist image as album art", func() {
|
It("does not use the artist image as album art", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||||
|
|
@ -366,7 +366,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("does not use the artist image as album art for the spread album", func() {
|
It("does not use the artist image as album art for the spread album", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||||
|
|
@ -392,7 +392,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── CD2/
|
// └── CD2/
|
||||||
// └── 01 - Track.mp3
|
// └── 01 - Track.mp3
|
||||||
It("does not adopt the library-root image as album art", func() {
|
It("does not adopt the library-root image as album art", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"cover.jpg": smallPNG("library-root"),
|
"cover.jpg": smallPNG("library-root"),
|
||||||
"CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}),
|
"CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}),
|
||||||
|
|
@ -412,7 +412,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← should win
|
// └── cover.jpg ← should win
|
||||||
It("prefers the album's own cover over the shallower artist-folder cover", func() {
|
It("prefers the album's own cover over the shallower artist-folder cover", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
|
@ -435,7 +435,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── Album B/
|
// └── Album B/
|
||||||
// └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
|
// └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
|
||||||
It("prefers the album's own art over the artist image", func() {
|
It("prefers the album's own art over the artist image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||||
|
|
@ -458,7 +458,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 (no embedded picture)
|
// ├── 01 - Track.mp3 (no embedded picture)
|
||||||
// └── cover.jpg ← wins (embedded skipped, falls through)
|
// └── cover.jpg ← wins (embedded skipped, falls through)
|
||||||
It("falls through to the next priority entry", func() {
|
It("falls through to the next priority entry", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "embedded, cover.*" })
|
conf.Server.CoverArtPriority = "embedded, cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3
|
// └── 01 - Track.mp3
|
||||||
It("returns the artist.* image from the artist folder", func() {
|
It("returns the artist.* image from the artist folder", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||||
|
|
@ -46,7 +46,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── artist.jpg ← matched by album/artist.*
|
// └── artist.jpg ← matched by album/artist.*
|
||||||
It("falls through to album/artist.* and returns that image", func() {
|
It("falls through to album/artist.* and returns that image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||||
|
|
@ -63,7 +63,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── artist.jpg
|
// └── artist.jpg
|
||||||
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
|
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||||
|
|
@ -80,7 +80,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3
|
// └── 01 - Track.mp3
|
||||||
It("still resolves the artist folder and returns artist.*", func() {
|
It("still resolves the artist folder and returns artist.*", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*" })
|
conf.Server.ArtistArtPriority = "artist.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||||
|
|
@ -90,28 +90,6 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
When("the artist's only album folder has no images of its own", func() {
|
|
||||||
// Artist/
|
|
||||||
// ├── backdrop1.jpg
|
|
||||||
// ├── folder.jpg ← matched by folder.*
|
|
||||||
// ├── logo.png
|
|
||||||
// └── Album/
|
|
||||||
// ├── 01 - Track.mp3
|
|
||||||
// └── 02 - Track.mp3
|
|
||||||
It("resolves the artist folder, not the library root", func() {
|
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "folder.*, artist.*, album/artist.*" })
|
|
||||||
setLayout(fstest.MapFS{
|
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
|
||||||
"Artist/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
|
||||||
"Artist/backdrop1.jpg": smallPNG("backdrop"),
|
|
||||||
"Artist/folder.jpg": smallPNG("artist-folder"),
|
|
||||||
"Artist/logo.png": smallPNG("logo"),
|
|
||||||
})
|
|
||||||
scan()
|
|
||||||
expectArtistFolder(soleArtist(), "Artist/folder.jpg")
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
When("the artist's only album has its tracks in disc subfolders", func() {
|
When("the artist's only album has its tracks in disc subfolders", func() {
|
||||||
// Artist/
|
// Artist/
|
||||||
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
// ├── artist.jpg ← wins (artist.* before album/artist.*)
|
||||||
|
|
@ -120,7 +98,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── CD1/01 - Track.mp3
|
// ├── CD1/01 - Track.mp3
|
||||||
// └── CD2/02 - Track.mp3
|
// └── CD2/02 - Track.mp3
|
||||||
It("prefers the artist-folder image over the album-folder one", func() {
|
It("prefers the artist-folder image over the album-folder one", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||||
"Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
"Artist/Album/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
|
||||||
|
|
@ -141,7 +119,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// │ └── CD2/02 - Track.mp3
|
// │ └── CD2/02 - Track.mp3
|
||||||
// └── Album2/03 - Track.mp3
|
// └── Album2/03 - Track.mp3
|
||||||
It("prefers the artist-folder image over the album-folder one", func() {
|
It("prefers the artist-folder image over the album-folder one", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||||
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||||
|
|
@ -165,7 +143,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── CD1/03 - Track.mp3
|
// ├── CD1/03 - Track.mp3
|
||||||
// └── CD2/04 - Track.mp3
|
// └── CD2/04 - Track.mp3
|
||||||
It("prefers the artist-folder image over the album-folder one", func() {
|
It("prefers the artist-folder image over the album-folder one", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
"Artist/Album1/CD1/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||||
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
"Artist/Album1/CD2/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album1"}),
|
||||||
|
|
@ -190,7 +168,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3
|
// └── 01 - Track.mp3
|
||||||
It("prefers the uploaded image over any priority-chain match", func() {
|
It("prefers the uploaded image over any priority-chain match", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||||
|
|
@ -201,7 +179,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
uploaded := ar.ID + "_upload.jpg"
|
uploaded := ar.ID + "_upload.jpg"
|
||||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||||
ar.UploadedImage = uploaded
|
ar.UploadedImage = uploaded
|
||||||
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
|
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -215,7 +193,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── artist.jpg ← matched by album/artist.*
|
// └── artist.jpg ← matched by album/artist.*
|
||||||
It("resolves the pattern against the artist's album image files", func() {
|
It("resolves the pattern against the artist's album image files", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||||
|
|
@ -233,7 +211,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── artist.jpg ← must NOT become the artist image
|
// └── artist.jpg ← must NOT become the artist image
|
||||||
It("ignores the album's images and settles absent", func() {
|
It("ignores the album's images and settles absent", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "album/artist.*" })
|
conf.Server.ArtistArtPriority = "album/artist.*"
|
||||||
// " / " is a default artists split separator, so this single tag yields two album artists.
|
// " / " is a default artists split separator, so this single tag yields two album artists.
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}),
|
"Artist/Collab Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist / Collaborator"}),
|
||||||
|
|
@ -261,10 +239,8 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
It("returns the image from the configured artist image folder", func() {
|
It("returns the image from the configured artist image folder", func() {
|
||||||
imgFolder := GinkgoT().TempDir()
|
imgFolder := GinkgoT().TempDir()
|
||||||
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed())
|
Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), pngBytes("image-folder"), 0o600)).To(Succeed())
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.ArtistImageFolder = imgFolder
|
||||||
c.ArtistImageFolder = imgFolder
|
conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
|
||||||
c.ArtistArtPriority = "image-folder, artist.*, album/artist.*"
|
|
||||||
})
|
|
||||||
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
|
@ -281,7 +257,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
|
|
||||||
func soleArtist() model.Artist {
|
func soleArtist() model.Artist {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
|
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── disc1.jpg ← matched by disc*.*
|
// └── disc1.jpg ← matched by disc*.*
|
||||||
It("returns the disc1.jpg image (matched as disc*.*)", func() {
|
It("returns the disc1.jpg image (matched as disc*.*)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" })
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/disc1.jpg": smallPNG("disc1-image"),
|
"Artist/Album/disc1.jpg": smallPNG("disc1-image"),
|
||||||
|
|
@ -38,10 +38,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3 (no disc or album art — nothing to serve)
|
// └── 01 - Track.mp3 (no disc or album art — nothing to serve)
|
||||||
It("reports the disc lookup as unavailable", func() {
|
It("reports the disc lookup as unavailable", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||||
c.DiscArtPriority = "disc*.*, cd*.*"
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
c.CoverArtPriority = "cover.*, folder.*"
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
})
|
})
|
||||||
|
|
@ -56,10 +54,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← album-level fallback (no disc art present)
|
// └── cover.jpg ← album-level fallback (no disc art present)
|
||||||
It("falls back to the album cover", func() {
|
It("falls back to the album cover", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||||
c.DiscArtPriority = "disc*.*, cd*.*"
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||||
|
|
@ -76,7 +72,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── disc1.jpg ← matches request for disc 1
|
// ├── disc1.jpg ← matches request for disc 1
|
||||||
// └── disc10.jpg
|
// └── disc10.jpg
|
||||||
It("matches the requested disc number, not a higher-numbered one", func() {
|
It("matches the requested disc number, not a higher-numbered one", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "disc*.*" })
|
conf.Server.DiscArtPriority = "disc*.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
|
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
|
||||||
|
|
@ -97,7 +93,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── disc2.jpg ← matches request for disc 2
|
// └── disc2.jpg ← matches request for disc 2
|
||||||
It("returns the requested disc's image", func() {
|
It("returns the requested disc's image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "disc*.*" })
|
conf.Server.DiscArtPriority = "disc*.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -122,7 +118,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cd2.png ← matched by cd*.* for disc 2
|
// └── cd2.png ← matched by cd*.* for disc 2
|
||||||
It("matches via the cd*.* pattern", func() {
|
It("matches via the cd*.* pattern", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = defaultDiscPriority })
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -144,7 +140,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("falls through to cover.* inside the disc folder", func() {
|
It("falls through to cover.* inside the disc folder", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = defaultDiscPriority })
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -167,10 +163,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// │ └── cd2.png (ignored — DiscArtPriority is empty)
|
// │ └── cd2.png (ignored — DiscArtPriority is empty)
|
||||||
// └── cover.jpg ← used for every disc (album-level fallback)
|
// └── cover.jpg ← used for every disc (album-level fallback)
|
||||||
It("skips every disc-level source and returns the album cover", func() {
|
It("skips every disc-level source and returns the album cover", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = ""
|
||||||
c.DiscArtPriority = ""
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -200,10 +194,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// │ └── 02 - Track.mp3
|
// │ └── 02 - Track.mp3
|
||||||
// └── cover.jpg (album-level fallback, unused here)
|
// └── cover.jpg (album-level fallback, unused here)
|
||||||
It("matches the per-disc image for each disc", func() {
|
It("matches the per-disc image for each disc", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
|
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
|
||||||
|
|
@ -227,7 +219,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||||
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
|
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
|
||||||
It("selects the subtitle-named image", func() {
|
It("selects the subtitle-named image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "discsubtitle" })
|
conf.Server.DiscArtPriority = "discsubtitle"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||||
"Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"),
|
"Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"),
|
||||||
|
|
@ -250,10 +242,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// │ └── folder.jpg
|
// │ └── folder.jpg
|
||||||
// └── ... (12 discs)
|
// └── ... (12 discs)
|
||||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
discNames := []string{
|
discNames := []string{
|
||||||
"Disc 01 (Birth of the Dead - The Studio Sides)",
|
"Disc 01 (Birth of the Dead - The Studio Sides)",
|
||||||
"Disc 02 (Birth of the Dead - The Live Sides)",
|
"Disc 02 (Birth of the Dead - The Live Sides)",
|
||||||
|
|
@ -304,10 +294,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── folder.jpg
|
// └── folder.jpg
|
||||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
layout := fstest.MapFS{
|
layout := fstest.MapFS{
|
||||||
"Album/cover.jpg": smallPNG("album-root-cover"),
|
"Album/cover.jpg": smallPNG("album-root-cover"),
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +321,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||||
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
|
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
|
||||||
It("falls through to the next priority entry", func() {
|
It("falls through to the next priority entry", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "discsubtitle, cover.*" })
|
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor
|
_ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
_ "github.com/navidrome/navidrome/core/storage/local"
|
_ "github.com/navidrome/navidrome/core/storage/local"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
|
@ -28,8 +27,6 @@ func TestArtworkE2E(t *testing.T) {
|
||||||
RunSpecs(t, "Artwork Pipeline E2E Suite")
|
RunSpecs(t, "Artwork Pipeline E2E Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
||||||
// Fixtures relative to the project root (tests.Init chdirs there).
|
// Fixtures relative to the project root (tests.Init chdirs there).
|
||||||
const (
|
const (
|
||||||
coverFixture = "tests/fixtures/artist/an-album/cover.jpg"
|
coverFixture = "tests/fixtures/artist/an-album/cover.jpg"
|
||||||
|
|
@ -59,8 +56,7 @@ func runWorkerUntil(ctx context.Context, worker *artwork.Worker, until func() bo
|
||||||
runCtx, cancel := context.WithCancel(ctx)
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- worker.Run(runCtx) }()
|
go func() { done <- worker.Run(runCtx) }()
|
||||||
// Long enough for one retry (3-7s backoff, 5s poll tick).
|
Eventually(until, 5*time.Second, 10*time.Millisecond).Should(BeTrue())
|
||||||
Eventually(until, 15*time.Second, 10*time.Millisecond).Should(BeTrue())
|
|
||||||
cancel()
|
cancel()
|
||||||
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
Eventually(done, 2*time.Second).Should(Receive(BeNil()))
|
||||||
}
|
}
|
||||||
|
|
@ -70,17 +66,13 @@ type fakeFolderRepo struct {
|
||||||
result []model.Folder
|
result []model.Folder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
||||||
return f.result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
||||||
return nil, model.ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeUpload(entityType, name, srcFixture string) string {
|
func writeUpload(entityType, name, srcFixture string) string {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,8 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
// │ └── disc2.jpg ← wins (disc-level before album-level)
|
// │ └── disc2.jpg ← wins (disc-level before album-level)
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("falls back to the disc-level artwork (not the album cover)", func() {
|
It("falls back to the disc-level artwork (not the album cover)", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -59,10 +57,8 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 ← track requested
|
// ├── 01 - Track.mp3 ← track requested
|
||||||
// └── cover.jpg ← wins (album-level fallback, no disc subfolder)
|
// └── cover.jpg ← wins (album-level fallback, no disc subfolder)
|
||||||
It("falls back to the album cover", func() {
|
It("falls back to the album cover", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||||
|
|
@ -83,10 +79,8 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
// │ └── 01 - Track.mp3 ← track requested
|
// │ └── 01 - Track.mp3 ← track requested
|
||||||
// └── cover.jpg ← wins (no disc image → album-level fallback)
|
// └── cover.jpg ← wins (no disc image → album-level fallback)
|
||||||
It("falls through from disc to album cover", func() {
|
It("falls through from disc to album cover", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -104,7 +98,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3 ← has embedded picture (wins over every fallback)
|
// └── 01 - Track.mp3 ← has embedded picture (wins over every fallback)
|
||||||
It("resolves the track's embedded image into the store", func() {
|
It("resolves the track's embedded image into the store", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
})
|
})
|
||||||
|
|
@ -124,7 +118,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 ← has embedded picture (must NOT be served)
|
// ├── 01 - Track.mp3 ← has embedded picture (must NOT be served)
|
||||||
// └── cover.jpg ← wins (per-track art disabled at serve time)
|
// └── cover.jpg ← wins (per-track art disabled at serve time)
|
||||||
It("serves the album cover instead of the track's embedded art", func() {
|
It("serves the album cover instead of the track's embedded art", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||||
|
|
@ -133,7 +127,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
replaceWithRealMP3("Artist/Album/01 - Track.mp3")
|
||||||
|
|
||||||
// The setting is not part of the artwork fingerprint, so it must be honored at serve time.
|
// The setting is not part of the artwork fingerprint, so it must be honored at serve time.
|
||||||
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = false })
|
conf.Server.EnableMediaFileCoverArt = false
|
||||||
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
mf := mediafileOn("Artist/Album/01 - Track.mp3")
|
||||||
trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil)
|
trackArtID := model.NewArtworkID(model.KindMediaFileArtwork, mf.ID, nil)
|
||||||
Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover")))
|
Expect(serveBytes(trackArtID)).To(Equal(pngBytes("album-cover")))
|
||||||
|
|
@ -143,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
|
|
||||||
func mediafileOn(relPath string) model.MediaFile {
|
func mediafileOn(relPath string) model.MediaFile {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
|
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
||||||
Filters: squirrel.Like{"media_file.path": relPath},
|
Filters: squirrel.Like{"media_file.path": relPath},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
// <tempdir>/
|
// <tempdir>/
|
||||||
// └── cover.jpg ← absolute path stored in ExternalImageURL
|
// └── cover.jpg ← absolute path stored in ExternalImageURL
|
||||||
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
|
It("returns the local file regardless of EnableM3UExternalAlbumArt", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = false }) // local paths bypass the toggle
|
conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, pngBytes("external-local"), 0o600)).To(Succeed())
|
||||||
|
|
@ -96,7 +96,7 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
|
When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() {
|
||||||
// (no local files — the http source is gated off, so resolution settles absent)
|
// (no local files — the http source is gated off, so resolution settles absent)
|
||||||
It("skips the URL and settles absent", func() {
|
It("skips the URL and settles absent", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = false })
|
conf.Server.EnableM3UExternalAlbumArt = false
|
||||||
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
|
pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"})
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
|
|
@ -133,7 +133,7 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
// Playlist "pl-7" references tracks from both albums, so the worker generates a tiled
|
// Playlist "pl-7" references tracks from both albums, so the worker generates a tiled
|
||||||
// cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid).
|
// cover from 2 distinct album art tiles (mirrored to fill the 2x2 grid).
|
||||||
It("generates a tiled cover from album art", func() {
|
It("generates a tiled cover from album art", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
|
"Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}),
|
||||||
"Artist/AlbumA/cover.png": smallPNG("albumA"),
|
"Artist/AlbumA/cover.png": smallPNG("albumA"),
|
||||||
|
|
@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
})
|
})
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(2))
|
Expect(mfs).To(HaveLen(2))
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -171,7 +171,7 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
// ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3
|
// ├── AlbumC/{01 - Track.mp3, cover.png} ← tile 3
|
||||||
// └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4
|
// └── AlbumD/{01 - Track.mp3, cover.png} ← tile 4
|
||||||
It("fills all four grid quadrants with distinct album art", func() {
|
It("fills all four grid quadrants with distinct album art", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
layout := fstest.MapFS{}
|
layout := fstest.MapFS{}
|
||||||
for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} {
|
for _, name := range []string{"AlbumA", "AlbumB", "AlbumC", "AlbumD"} {
|
||||||
layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name})
|
layout["Artist/"+name+"/01 - Track.mp3"] = trackFile(1, "T"+name, map[string]any{"album": name})
|
||||||
|
|
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
setLayout(layout)
|
setLayout(layout)
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(4))
|
Expect(mfs).To(HaveLen(4))
|
||||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID(ids)
|
pl.AddMediaFilesByID(ids)
|
||||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
||||||
if pl.OwnerID == "" {
|
if pl.OwnerID == "" {
|
||||||
pl.OwnerID = "admin-1"
|
pl.OwnerID = "admin-1"
|
||||||
}
|
}
|
||||||
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
It("returns the uploaded image bytes", func() {
|
It("returns the uploaded image bytes", func() {
|
||||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
// (no files on disk — the resolver has no sources to fall back to)
|
// (no files on disk — the resolver has no sources to fall back to)
|
||||||
It("settles absent", func() {
|
It("settles absent", func() {
|
||||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||||
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
|
|
|
||||||
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