Compare commits

..

6 commits

Author SHA1 Message Date
Deluan
ec6f3e1914 refactor(release): select podcast releases with from and to
Signed-off-by: Deluan <deluan@navidrome.org>
2026-10-01 19:36:16 -04:00
Deluan
a607858873 fix(release): correct podcast reservations and prerelease validation
Signed-off-by: Deluan <deluan@navidrome.org>
2026-10-01 19:31:31 -04:00
Deluan
d315c2fe0b test(release): isolate local podcast fixtures from CI context
Signed-off-by: Deluan <deluan@navidrome.org>
2026-10-01 18:50:17 -04:00
Deluan
82eea674a5 feat(release): replace audio helper with Go podcast CLI
Signed-off-by: Deluan <deluan@navidrome.org>
2026-10-01 18:47:10 -04:00
Deluan
e3fde1fd74 fix(release): install MP3 tools before audio generation
Signed-off-by: Deluan <deluan@navidrome.org>
2026-10-01 18:12:56 -04:00
Deluan
9be5c6f94b feat(release): add artifact-only release audio workflow
Signed-off-by: Deluan <deluan@navidrome.org>
2026-10-01 18:05:35 -04:00
436 changed files with 5955 additions and 8820 deletions

View file

@ -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:

View file

@ -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.

View file

@ -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.
--> -->

View file

@ -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$'`
@ -519,18 +519,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 +541,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 +558,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

View file

@ -0,0 +1,29 @@
name: Release podcast offline tests
on:
pull_request:
paths:
- release/podcast/**
- .github/workflows/release-podcast*.yml
push:
branches: [master]
paths:
- release/podcast/**
- .github/workflows/release-podcast*.yml
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: go.mod
- name: Install tools for the offline MP3 decode test
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg
- run: go test -race -count=1 -v ./release/podcast

126
.github/workflows/release-podcast.yml vendored Normal file
View file

@ -0,0 +1,126 @@
name: Release podcast
on:
release:
types: [published]
workflow_dispatch:
inputs:
from:
description: Published version, or inclusive first version of a range
default: v0.64.0
required: true
type: string
to:
description: Optional inclusive last version (at most three releases)
default: v0.64.2
required: false
type: string
mode:
description: Validate is free; script and audio use OpenAI
default: validate
type: choice
options: [validate, script, audio]
include_prereleases:
description: Allow published prereleases (manual only)
default: false
type: boolean
force_regenerate:
description: Allow another paid attempt even if reserved before
default: false
type: boolean
permissions: {}
# Serialize the artifact lookup and reservation, including overlapping rollups.
# GitHub may replace an older pending run; recover that run with manual dispatch.
concurrency:
group: release-podcast-${{ github.repository }}
cancel-in-progress: false
jobs:
generate:
if: >-
github.repository == 'navidrome/navidrome' &&
((github.event_name == 'release' && !github.event.release.draft && !github.event.release.prerelease && vars.RELEASE_AUDIO_ENABLED == 'true') ||
(github.event_name == 'workflow_dispatch' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)))
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
actions: read
env:
AUDIO_MODE: ${{ github.event_name == 'release' && 'audio' || inputs.mode }}
AUDIO_ENABLED: ${{ vars.RELEASE_AUDIO_ENABLED }}
AUDIO_TEXT_MODEL: ${{ vars.RELEASE_AUDIO_TEXT_MODEL }}
AUDIO_TTS_MODEL: ${{ vars.RELEASE_AUDIO_TTS_MODEL }}
AUDIO_VOICE: ${{ vars.RELEASE_AUDIO_VOICE }}
steps:
# Always execute the reviewed default-branch helper, never release-note data.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version-file: go.mod
- name: Build the shared Go CLI before exposing credentials
run: go build -o "$RUNNER_TEMP/release-podcast" ./release/podcast
- name: Install MP3 inspection tools before generation
if: env.AUDIO_MODE == 'audio' && env.AUDIO_ENABLED == 'true'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg
- name: Resolve sources and check limits and duplicate attempts
id: prepare
env:
GH_TOKEN: ${{ github.token }}
AUDIO_KEY_CONFIGURED: ${{ secrets.OPENAI_API_KEY != '' }}
run: '"$RUNNER_TEMP/release-podcast" --github-stage prepare'
- name: Reserve this paid attempt before contacting OpenAI
if: steps.prepare.outputs.generate == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
# Exact deterministic name supports API filtering across runs.
# Keep immutable within a run; force requires a fresh dispatch.
name: ${{ steps.prepare.outputs.reservation }}
path: release-podcast/manifest.json
if-no-files-found: error
retention-days: 90
- name: Generate and validate the grounded script
if: steps.prepare.outputs.generate == 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GH_TOKEN: ${{ github.token }}
run: '"$RUNNER_TEMP/release-podcast" --github-stage script'
- name: Checkpoint the validated script
if: steps.prepare.outputs.generate == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-podcast-script-${{ github.run_id }}-${{ github.run_attempt }}
path: |
release-podcast/transcript.txt
release-podcast/sources.json
release-podcast/evidence.json
release-podcast/manifest.json
if-no-files-found: error
retention-days: 30
- name: Synthesize and inspect the MP3
if: steps.prepare.outputs.generate == 'true' && env.AUDIO_MODE == 'audio'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GH_TOKEN: ${{ github.token }}
run: '"$RUNNER_TEMP/release-podcast" --github-stage speech'
- name: Upload review artifacts
if: always() && steps.prepare.outputs.prepared == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-podcast-${{ github.run_id }}-${{ github.run_attempt }}
path: |
release-podcast/transcript.txt
release-podcast/release-podcast.mp3
release-podcast/sources.json
release-podcast/evidence.json
release-podcast/manifest.json
if-no-files-found: error
retention-days: 30
compression-level: 0

View file

@ -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.

View file

@ -18,7 +18,7 @@ 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.14.0
VACUUM_VERSION ?= v0.30.6 VACUUM_VERSION ?= v0.30.6
@ -223,7 +223,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

View file

@ -5,7 +5,7 @@
[![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases) [![Last Release](https://img.shields.io/github/v/release/navidrome/navidrome?logo=github&label=latest&style=flat-square)](https://github.com/navidrome/navidrome/releases)
[![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master) [![Build](https://img.shields.io/github/actions/workflow/status/navidrome/navidrome/pipeline.yml?branch=master&logo=github&style=flat-square)](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
[![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest) [![Downloads](https://img.shields.io/github/downloads/navidrome/navidrome/total?logo=github&style=flat-square)](https://github.com/navidrome/navidrome/releases/latest)
[![Docker Pulls](https://img.shields.io/endpoint?url=https%3A%2F%2Fwww.navidrome.org%2Fbadges%2Fcontainer-downloads.json&logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/navidrome/navidrome) [![Docker Pulls](https://img.shields.io/docker/pulls/deluan/navidrome?logo=docker&label=pulls&style=flat-square)](https://hub.docker.com/r/deluan/navidrome)
[![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF) [![Dev Chat](https://img.shields.io/discord/671335427726114836?logo=discord&label=discord&style=flat-square)](https://discord.gg/xh7j7yF)
[![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/) [![Subreddit](https://img.shields.io/reddit/subreddit-subscribers/navidrome?logo=reddit&label=/r/navidrome&style=flat-square)](https://www.reddit.com/r/navidrome/)
[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](CODE_OF_CONDUCT.md) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-v2.0-ff69b4.svg?style=flat-square)](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>
[![PikaPods](https://www.pikapods.com/static/run-button.svg)](https://www.pikapods.com/pods?run=navidrome)
## Features ## Features

View file

@ -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)
} }
}) })

View file

@ -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()

View file

@ -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),

View file

@ -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() {

View file

@ -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)
@ -354,7 +354,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

View file

@ -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)
@ -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() {

View file

@ -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()

View file

@ -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()

View file

@ -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

View file

@ -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
} }

View file

@ -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)

View file

@ -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}

View file

@ -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()

View file

@ -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)
} }
@ -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 "", ""
} }
@ -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:

View file

@ -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",
@ -577,11 +578,10 @@ var _ = Describe("reprocessArtwork", 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
})
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().(*tests.MockArtworkRepo)
@ -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 }
@ -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())
}) })
}) })

View file

@ -15,7 +15,6 @@ var (
backupCount int backupCount int
backupDir string backupDir string
force bool force bool
integrityCheck bool
restorePath string restorePath string
) )
@ -32,7 +31,6 @@ func init() {
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", "", "file name of the backup database to restore (resolved against the backup directory unless it is an absolute path)")
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,7 +73,7 @@ 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() requireExistingDB()
@ -83,7 +81,7 @@ func runBackup(ctx context.Context) {
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", "backupPath", conf.Server.Backup.Path, err)
} }
elapsed := time.Since(start) elapsed := time.Since(start)
@ -91,16 +89,15 @@ 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 && !confirmYES("Warning: pruning ALL backups") {
log.Warn("Prune cancelled") log.Warn("Prune cancelled")
return return
} }
@ -110,7 +107,7 @@ func runPrune(ctx context.Context) {
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 database", "backupPath", conf.Server.Backup.Path, err)
} }
elapsed := time.Since(start) elapsed := time.Since(start)
@ -125,9 +122,9 @@ func runRestore(ctx context.Context) {
// `backup create` writes to. Without this, the value was treated as relative // `backup create` writes to. Without this, the value was treated as relative
// to the working directory, where the file does not exist. // to the working directory, where the file does not exist.
if !filepath.IsAbs(restorePath) { if !filepath.IsAbs(restorePath) {
backupPath, err := conf.Server().Backup.Path.Path() backupPath, err := conf.Server.Backup.Path.Path()
if err != nil { if err != nil {
log.Fatal("Backup directory not available", "backupPath", conf.Server().Backup.Path, err) log.Fatal("Backup directory not available", "backupPath", conf.Server.Backup.Path, err)
return return
} }
restorePath = filepath.Join(backupPath, restorePath) restorePath = filepath.Join(backupPath, restorePath)
@ -139,7 +136,7 @@ func runRestore(ctx context.Context) {
} }
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", "backupFile", restorePath, err)
} }

View file

@ -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()

View file

@ -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
}

View file

@ -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())
})
})
})

View file

@ -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)")
} }
} }

View file

@ -97,7 +97,7 @@ 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 {
@ -128,31 +128,31 @@ 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.DevAPIv1 {
a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx)) a.MountRouter("API v1", consts.URLPathAPIv1, CreateAPIv1Router(ctx))
} }
if conf.Server().Prometheus.Enabled { if conf.Server.Prometheus.Enabled {
p := CreatePrometheus() p := CreatePrometheus()
// blocking call because takes <100ms but useful if fails // blocking call because takes <100ms but useful if fails
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 +160,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,20 +190,16 @@ 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().DefaultGet(context.Background(), 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().DefaultGet(context.Background(), 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.
@ -218,12 +214,12 @@ func runInitialScan(ctx context.Context) func() error {
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)
@ -231,9 +227,9 @@ func runInitialScan(ctx context.Context) func() error {
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().Delete(ctx, 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 +251,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 +266,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 +301,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 +335,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
} }
@ -358,7 +354,7 @@ func startInsightsCollector(ctx context.Context) func() error {
// startJellyfinDiscovery never returns an error: a discovery failure must not stop the server. // startJellyfinDiscovery never returns an error: a discovery failure must not stop the server.
func startJellyfinDiscovery(ctx context.Context) func() error { func startJellyfinDiscovery(ctx context.Context) func() error {
return func() error { return func() error {
if !conf.Server().Jellyfin.Enabled || !conf.Server().Jellyfin.AutoDiscovery { if !conf.Server.Jellyfin.Enabled || !conf.Server.Jellyfin.AutoDiscovery {
log.Debug("Jellyfin auto-discovery is DISABLED") log.Debug("Jellyfin auto-discovery is DISABLED")
return nil return nil
} }
@ -371,7 +367,7 @@ func startJellyfinDiscovery(ctx context.Context) func() error {
// 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
} }
@ -439,7 +435,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
} }

View file

@ -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"))
})
})

View file

@ -81,13 +81,13 @@ var svcInstance = sync.OnceValue(func() service.Service {
options["Restart"] = "on-failure" options["Restart"] = "on-failure"
options["SuccessExitStatus"] = "SIGKILL" options["SuccessExitStatus"] = "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 +101,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 +135,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 {

View file

@ -18,16 +18,11 @@ import (
"github.com/navidrome/navidrome/persistence" "github.com/navidrome/navidrome/persistence"
) )
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists. // requireExistingDB aborts the command when the database file (DbPath minus DSN
func existingDBFile() (string, bool) { // params) does not exist.
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() { func requireExistingDB() {
if path, ok := existingDBFile(); !ok { path, _, _ := strings.Cut(conf.Server.DbPath, "?")
if _, err := os.Stat(path); os.IsNotExist(err) {
log.Fatal("No existing database", "path", path) log.Fatal("No existing database", "path", path)
} }
} }

View file

@ -95,9 +95,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, artworkArtwork)
players := core.NewPlayers(dataStore) players := core.NewPlayers(dataStore)
broker := events.GetBroker() broker := events.GetBroker()
metricsMetrics := metrics.GetPrometheusInstance(dataStore) metricsMetrics := metrics.GetPrometheusInstance(dataStore)
@ -111,6 +110,7 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
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
@ -159,10 +159,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, artworkArtwork)
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver) router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
return router return router
} }

View file

@ -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())
}
})
} }

View file

@ -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()

View file

@ -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"))
})
})
})

View file

@ -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
@ -160,7 +161,6 @@ type Options struct {
DevExternalArtistFetchMultiplier float64 DevExternalArtistFetchMultiplier float64
DevPreserveUnicodeInExternalCalls bool DevPreserveUnicodeInExternalCalls bool
DevEnableMediaFileProbe bool DevEnableMediaFileProbe bool
DevMatchEstimatedContentLength bool
DevAPIv1 bool DevAPIv1 bool
} }
@ -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"
} }
@ -321,11 +320,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 +372,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 +384,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, 0600)
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 +423,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 +450,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 +487,47 @@ 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 { if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
log.Warn("Invalid Scanner.Extractor, using default", "value", s.Scanner.Extractor, "default", consts.DefaultScannerExtractor) log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
s.Scanner.Extractor = consts.DefaultScannerExtractor Server.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 +768,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 +803,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 +842,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 +869,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 +881,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
} }
@ -1055,7 +1070,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)
@ -1138,7 +1152,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() {

View file

@ -23,8 +23,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 +67,13 @@ 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())
}) })
}) })
@ -160,9 +158,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 +180,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 +254,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 +296,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())
}) })
}) })
}) })
@ -394,13 +392,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"))
}) })
}) })
@ -416,12 +414,12 @@ var _ = Describe("Configuration", func() {
It("falls back to taglib for an unknown extractor", func() { It("falls back to taglib for an unknown extractor", func() {
viper.SetDefault("scanner.extractor", "ffmpeg") viper.SetDefault("scanner.extractor", "ffmpeg")
conf.Load(true) conf.Load(true)
Expect(conf.Server().Scanner.Extractor).To(Equal("taglib")) Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
}) })
It("keeps taglib", func() { It("keeps taglib", func() {
conf.Load(true) conf.Load(true)
Expect(conf.Server().Scanner.Extractor).To(Equal("taglib")) Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
}) })
}) })
@ -429,7 +427,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 +436,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 +462,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 +476,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 +498,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 +520,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 +538,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())
}) })
}) })

View file

@ -1,7 +1,7 @@
package conf package conf
func ResetConf() { func ResetConf() {
current.Store(&Options{}) Server = &configOptions{}
} }
var SetViperDefaults = setViperDefaults var SetViperDefaults = setViperDefaults

View file

@ -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)
}
}

View file

@ -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))
})
})

View file

@ -90,7 +90,6 @@ const (
I18nFolder = "i18n" I18nFolder = "i18n"
ScanIgnoreFile = ".ndignore" ScanIgnoreFile = ".ndignore"
NoMediaFile = ".nomedia"
ArtworkFolder = "artwork" ArtworkFolder = "artwork"
// 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.
@ -157,6 +156,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 (

View file

@ -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)

View file

@ -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")

View file

@ -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()

View file

@ -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())
}) })

View file

@ -35,14 +35,13 @@ 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, artwork artwork.Artwork) Archiver {
return &archiver{ds: ds, ms: ms, decider: decider, shares: shares, artwork: artwork} return &archiver{ds: ds, ms: ms, shares: shares, artwork: artwork}
} }
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 artwork artwork.Artwork
} }
@ -79,9 +78,8 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder, 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, folder)
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
@ -206,9 +204,8 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
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()
@ -254,14 +251,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 +259,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() {

View file

@ -10,6 +10,7 @@ import (
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/core/stream"
@ -25,7 +26,6 @@ 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 ca *mockCoverArt
@ -33,11 +33,10 @@ var _ = Describe("Archiver", func() {
BeforeEach(func() { BeforeEach(func() {
ms = &mockMediaStreamer{} ms = &mockMediaStreamer{}
dc = &fakeDecider{}
sh = &mockShare{} sh = &mockShare{}
ds = &mockDataStore{} ds = &mockDataStore{}
ca = &mockCoverArt{images: map[string][]byte{}} ca = &mockCoverArt{images: map[string][]byte{}}
arch = core.NewArchiver(ms, dc, ds, sh, ca) arch = core.NewArchiver(ms, ds, sh, ca)
}) })
Context("ZipAlbum", func() { Context("ZipAlbum", func() {
@ -67,23 +66,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() {
@ -118,6 +100,9 @@ var _ = Describe("Archiver", func() {
}) })
When("albums that share a name", func() { When("albums that share a name", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order. // zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
zipArtistEntries := func(mfs model.MediaFiles) []string { zipArtistEntries := func(mfs model.MediaFiles) []string {
@ -175,7 +160,7 @@ var _ = Describe("Archiver", func() {
}) })
It("prefers the album version over the year when it is not part of the name", func() { 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 }) conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{ names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, {Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}}, Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
@ -186,7 +171,7 @@ var _ = Describe("Archiver", func() {
}) })
It("leaves the one album without the field unsuffixed", func() { It("leaves the one album without the field unsuffixed", func() {
conf.Update(func(c *conf.Options) { c.Subsonic.AppendAlbumVersion = false }) conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{ names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, {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: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
@ -196,7 +181,7 @@ var _ = Describe("Archiver", func() {
}) })
It("skips a field that is empty on more than one album", func() { It("skips a field that is empty on more than one album", func() {
conf.Update(func(c *conf.Options) { c.Subsonic.AppendAlbumVersion = false }) conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{ names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001}, {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: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
@ -231,7 +216,7 @@ var _ = Describe("Archiver", func() {
}) })
It("sanitizes the suffix", func() { It("sanitizes the suffix", func() {
conf.Update(func(c *conf.Options) { c.Subsonic.AppendAlbumVersion = false }) conf.Server.Subsonic.AppendAlbumVersion = false
names := zipArtistEntries(model.MediaFiles{ names := zipArtistEntries(model.MediaFiles{
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}}, {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"}}}, {Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
@ -311,30 +296,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"}},
@ -610,19 +571,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

View file

@ -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)

View file

@ -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)

View file

@ -263,7 +263,7 @@ 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().Get(ctx, artID.ID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -287,7 +287,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
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 +317,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) {

View file

@ -12,7 +12,6 @@ import (
"testing" "testing"
"time" "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"
@ -42,8 +41,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. // productionImageClient keeps the guarded client for the specs that assert it refuses loopback.
var productionImageClient = remoteImageClient var productionImageClient = remoteImageClient

View file

@ -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"
@ -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()
@ -298,7 +298,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"}})
@ -321,7 +321,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 +333,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,
@ -360,7 +360,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,

View file

@ -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

View file

@ -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"
@ -63,19 +64,18 @@ var _ = Describe("Acquisition → serve loop", func() {
} }
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}})

View file

@ -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"),

View file

@ -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"),
@ -99,7 +99,7 @@ var _ = Describe("Artist artwork resolution", func() {
// ├── 01 - Track.mp3 // ├── 01 - Track.mp3
// └── 02 - Track.mp3 // └── 02 - Track.mp3
It("resolves the artist folder, not the library root", func() { It("resolves the artist folder, not the library root", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "folder.*, artist.*, album/artist.*" }) conf.Server.ArtistArtPriority = "folder.*, artist.*, album/artist.*"
setLayout(fstest.MapFS{ setLayout(fstest.MapFS{
"Artist/Album/01 - Track.mp3": trackFile(1, "Track 1", map[string]any{"albumartist": "Artist", "album": "Album"}), "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/Album/02 - Track.mp3": trackFile(2, "Track 2", map[string]any{"albumartist": "Artist", "album": "Album"}),
@ -120,7 +120,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 +141,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 +165,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 +190,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"),
@ -215,7 +215,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 +233,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 +261,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"}),

View file

@ -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"),

View file

@ -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"

View file

@ -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")))

View file

@ -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"),
@ -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})

View file

@ -19,6 +19,7 @@ import (
_ "github.com/navidrome/navidrome/adapters/gotaglib" _ "github.com/navidrome/navidrome/adapters/gotaglib"
"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"
@ -66,10 +67,9 @@ var userTables []string
var _ = BeforeSuite(func() { var _ = BeforeSuite(func() {
suiteDBTempDir = GinkgoT().TempDir() suiteDBTempDir = GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { DeferCleanup(configtest.SetupConfig())
c.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL"
c.DataFolder = conf.NewDir(GinkgoT().TempDir()) conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
})
db.Db().SetMaxOpenConns(1) db.Db().SetMaxOpenConns(1)
db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true})) db.Init(request.WithUser(context.Background(), model.User{ID: "admin-1", IsAdmin: true}))
@ -81,19 +81,17 @@ var _ = AfterSuite(func() {
}) })
func setupResolutionHarness() { func setupResolutionHarness() {
DeferCleanup(conf.SnapshotConfig()) DeferCleanup(configtest.SetupConfig())
tempDir := GinkgoT().TempDir() tempDir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL"
c.DbPath = filepath.Join(suiteDBTempDir, "artwork-resolution-e2e.db") + "?_journal_mode=WAL" conf.Server.DataFolder = conf.NewDir(tempDir)
c.DataFolder = conf.NewDir(tempDir) conf.Server.MusicFolder = fakeLibPath
c.MusicFolder = fakeLibPath conf.Server.DevExternalScanner = false
c.DevExternalScanner = false conf.Server.ImageCacheSize = "0"
c.ImageCacheSize = "0" conf.Server.EnableExternalServices = false
c.EnableExternalServices = false conf.Server.EnableMediaFileCoverArt = true
c.EnableMediaFileCoverArt = true conf.Server.DevArtworkWorkerConcurrency = 1
c.DevArtworkWorkerConcurrency = 1
})
rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) rctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true})
harness.TruncateDB(userTables) harness.TruncateDB(userTables)

View file

@ -75,7 +75,7 @@ func (w *Worker) gateFor(name string) *extGate {
if g, ok := w.gates[name]; ok { if g, ok := w.gates[name]; ok {
return g return g
} }
rps := conf.Server().DevArtworkExternalMaxRPS rps := conf.Server.DevArtworkExternalMaxRPS
limit := rate.Inf limit := rate.Inf
if rps > 0 { if rps > 0 {
limit = rate.Limit(rps) limit = rate.Limit(rps)

View file

@ -50,12 +50,12 @@ type FingerprintInput struct {
// FingerprintInputs is the single listing of what ConfigFingerprint hashes. // FingerprintInputs is the single listing of what ConfigFingerprint hashes.
func FingerprintInputs() []FingerprintInput { func FingerprintInputs() []FingerprintInput {
return []FingerprintInput{ return []FingerprintInput{
{"CoverArtPriority", conf.Server().CoverArtPriority}, {"CoverArtPriority", conf.Server.CoverArtPriority},
{"ArtistArtPriority", conf.Server().ArtistArtPriority}, {"ArtistArtPriority", conf.Server.ArtistArtPriority},
{"ArtistImageFolder", conf.Server().ArtistImageFolder}, {"ArtistImageFolder", conf.Server.ArtistImageFolder},
{"Agents", conf.Server().Agents}, {"Agents", conf.Server.Agents},
{"EnableExternalServices", strconv.FormatBool(conf.Server().EnableExternalServices)}, {"EnableExternalServices", strconv.FormatBool(conf.Server.EnableExternalServices)},
{"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server().EnableM3UExternalAlbumArt)}, {"EnableM3UExternalAlbumArt", strconv.FormatBool(conf.Server.EnableM3UExternalAlbumArt)},
} }
} }

View file

@ -5,6 +5,7 @@ import (
"slices" "slices"
"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/tests" "github.com/navidrome/navidrome/tests"
@ -34,13 +35,12 @@ var _ = Describe("Housekeeping", func() {
) )
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ctx = context.Background() ctx = context.Background()
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "embedded, folder"
c.CoverArtPriority = "embedded, folder" conf.Server.ArtistArtPriority = "artist.jpg"
c.ArtistArtPriority = "artist.jpg" conf.Server.Agents = "spotify"
c.Agents = "spotify" conf.Server.EnableExternalServices = true
c.EnableExternalServices = true
})
queueRepo = tests.CreateMockArtworkQueueRepo() queueRepo = tests.CreateMockArtworkQueueRepo()
propRepo = &tests.MockedPropertyRepo{} propRepo = &tests.MockedPropertyRepo{}
@ -49,35 +49,31 @@ var _ = Describe("Housekeeping", func() {
Describe("Fingerprint", func() { Describe("Fingerprint", func() {
DescribeTable("changes when a fingerprint-affecting config value changes", DescribeTable("changes when a fingerprint-affecting config value changes",
func(change func(*conf.Options)) { func(change func()) {
before := ConfigFingerprint() before := ConfigFingerprint()
conf.Update(change) change()
Expect(ConfigFingerprint()).NotTo(Equal(before)) Expect(ConfigFingerprint()).NotTo(Equal(before))
}, },
Entry("CoverArtPriority", func(c *conf.Options) { c.CoverArtPriority = "folder, embedded" }), Entry("CoverArtPriority", func() { conf.Server.CoverArtPriority = "folder, embedded" }),
Entry("ArtistImageFolder", func(c *conf.Options) { c.ArtistImageFolder = "/after" }), Entry("ArtistImageFolder", func() { conf.Server.ArtistImageFolder = "/after" }),
Entry("EnableM3UExternalAlbumArt", func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }), Entry("EnableM3UExternalAlbumArt", func() { conf.Server.EnableM3UExternalAlbumArt = true }),
) )
// Pinned: a changed formula tells every existing install its artwork config went stale. // Pinned: a changed formula tells every existing install its artwork config went stale.
It("hashes a given config to a stable value", func() { It("hashes a given config to a stable value", func() {
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "cover.*, embedded"
c.CoverArtPriority = "cover.*, embedded" conf.Server.ArtistArtPriority = "artist.*, external"
c.ArtistArtPriority = "artist.*, external" conf.Server.ArtistImageFolder = ""
c.ArtistImageFolder = "" conf.Server.Agents = "lastfm,spotify"
c.Agents = "lastfm,spotify" conf.Server.EnableExternalServices = true
c.EnableExternalServices = true conf.Server.EnableM3UExternalAlbumArt = false
c.EnableM3UExternalAlbumArt = false
})
Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d")) Expect(ConfigFingerprint()).To(Equal("7b538a83a870c16d"))
}) })
It("reports the config inputs it hashes, so a change can be traced to a setting", func() { It("reports the config inputs it hashes, so a change can be traced to a setting", func() {
conf.Update(func(c *conf.Options) { conf.Server.Agents = "lastfm,spotify"
c.Agents = "lastfm,spotify" conf.Server.CoverArtPriority = "cover.*, embedded"
c.CoverArtPriority = "cover.*, embedded"
})
Expect(FingerprintInputs()).To(ContainElements( Expect(FingerprintInputs()).To(ContainElements(
FingerprintInput{Name: "Agents", Value: "lastfm,spotify"}, FingerprintInput{Name: "Agents", Value: "lastfm,spotify"},

View file

@ -26,7 +26,7 @@ type imageCache struct {
func GetImageCache() cache.FileCache { func GetImageCache() cache.FileCache {
return singleton.GetInstance(func() *imageCache { return singleton.GetInstance(func() *imageCache {
return &imageCache{ return &imageCache{
FileCache: cache.NewFileCache("Image", conf.Server().ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems, FileCache: cache.NewFileCache("Image", conf.Server.ImageCacheSize, consts.ImageCacheDir, consts.DefaultImageCacheMaxItems,
func(ctx context.Context, arg cache.Item) (io.Reader, error) { func(ctx context.Context, arg cache.Item) (io.Reader, error) {
return arg.(artworkReader).Reader(ctx) return arg.(artworkReader).Reader(ctx)
}), }),

View file

@ -27,7 +27,7 @@ func NewImageStore(rootDir string) *ImageStore {
} }
func GetImageStore() *ImageStore { func GetImageStore() *ImageStore {
return NewImageStore(filepath.Join(conf.Server().DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder)) return NewImageStore(filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, consts.HashedArtworkFolder))
} }
// extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open. // extForMime must stay stable across OSes: extensions are baked into stored paths and re-derived on Open.

View file

@ -56,7 +56,7 @@ const thumbnailSize = 100
// maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at // maxImageBytes caps a resolved image read: a user-editable ExternalImageURL could point at
// an arbitrarily large endpoint. // an arbitrarily large endpoint.
func maxImageBytes() int64 { func maxImageBytes() int64 {
return parseSize(conf.Server().MaxImageSize, consts.DefaultMaxImageSize) return parseSize(conf.Server.MaxImageSize, consts.DefaultMaxImageSize)
} }
// maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that // maxImagePixels guards against decompression bombs: a tiny file can declare a canvas that

View file

@ -16,6 +16,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"
@ -56,6 +57,7 @@ var _ = Describe("processor.acquire", 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()
@ -76,7 +78,7 @@ var _ = Describe("processor.acquire", func() {
store = NewImageStore(GinkgoT().TempDir()) store = NewImageStore(GinkgoT().TempDir())
proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} proc = &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)}
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.jpg, embedded" }) conf.Server.CoverArtPriority = "cover.jpg, embedded"
}) })
It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() { It("found-folder: persists state from a folder image, writes no store file, keeps sourcePath/refMtime", func() {
@ -185,7 +187,7 @@ var _ = Describe("processor.acquire", func() {
}) })
It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() { It("failed-on-unreadable-local: a listed cover that will not open never records absent", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.jpg" }) conf.Server.CoverArtPriority = "cover.jpg"
// A folder listing that names a cover the FS will not hand over: a stale mount. // A folder listing that names a cover the FS will not hand over: a stale mount.
libRoot := GinkgoT().TempDir() libRoot := GinkgoT().TempDir()
Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed()) Expect(os.MkdirAll(filepath.Join(libRoot, "an-album"), 0o755)).To(Succeed())
@ -212,7 +214,7 @@ var _ = Describe("processor.acquire", func() {
radioRepo.Data = map[string]*model.Radio{} radioRepo.Data = map[string]*model.Radio{}
ds.MockedRadio = radioRepo ds.MockedRadio = radioRepo
dir := GinkgoT().TempDir() dir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(dir) }) conf.Server.DataFolder = conf.NewDir(dir)
upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg") upload := model.UploadedImagePath(consts.EntityRadio, "ra-io.jpg")
Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed()) Expect(os.MkdirAll(filepath.Dir(upload), 0o755)).To(Succeed())
Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed()) Expect(os.WriteFile(upload, []byte("x"), 0o600)).To(Succeed())
@ -238,7 +240,7 @@ var _ = Describe("processor.acquire", func() {
radioRepo.Data = map[string]*model.Radio{} radioRepo.Data = map[string]*model.Radio{}
ds.MockedRadio = radioRepo ds.MockedRadio = radioRepo
dir := GinkgoT().TempDir() dir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(dir) }) conf.Server.DataFolder = conf.NewDir(dir)
upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg") upload := model.UploadedImagePath(consts.EntityRadio, "ra-tr.jpg")
// A plain file where the upload's parent should be makes os.Open fault with ENOTDIR, // A plain file where the upload's parent should be makes os.Open fault with ENOTDIR,
// deterministically and regardless of the test user's privileges. // deterministically and regardless of the test user's privileges.
@ -257,7 +259,7 @@ var _ = Describe("processor.acquire", func() {
}) })
It("failed-on-extError: leaves the item's state untouched", func() { It("failed-on-extError: leaves the item's state untouched", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al4", Name: "Album"}, {ID: "al4", Name: "Album"},
}) })
@ -272,7 +274,7 @@ var _ = Describe("processor.acquire", func() {
}) })
It("failed-on-extError: reports the delay a throttled provider asked for", func() { It("failed-on-extError: reports the delay a throttled provider asked for", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al4r", Name: "Album"}, {ID: "al4r", Name: "Album"},
}) })
@ -284,7 +286,7 @@ var _ = Describe("processor.acquire", func() {
}) })
It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() { It("found-stale: a fallback hit after a transient external failure persists state and returns outcomeFoundStale", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external, cover.jpg" }) conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"}, ImageFiles: []string{"cover.jpg"},
@ -340,7 +342,7 @@ var _ = Describe("processor.acquire", func() {
// An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover. // An agent answering 200 with a non-image body must keep retrying, not pin garbage as a cover.
It("undecodable external body: fails without writing state", func() { It("undecodable external body: fails without writing state", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
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("<html>rate limited</html>")) _, _ = w.Write([]byte("<html>rate limited</html>"))
})) }))
@ -357,7 +359,7 @@ var _ = Describe("processor.acquire", func() {
}) })
It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() { It("found-external: persists source as external:<agentName> and stores the fetched bytes", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg")) imgBytes, err := os.ReadFile(filepath.Join(repoRoot, "tests/fixtures/artist/an-album/cover.jpg"))
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@ -468,7 +470,7 @@ var _ = Describe("processor.acquire", func() {
It("decode failure on found bytes: fails without writing state", func() { It("decode failure on found bytes: fails without writing state", func() {
tmpDir := GinkgoT().TempDir() tmpDir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(tmpDir) }) conf.Server.DataFolder = conf.NewDir(tmpDir)
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg") imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra1_test.jpg")
// Truncated PNG: a known format, so this is a real decode failure, not a missing decoder. // Truncated PNG: a known format, so this is a real decode failure, not a missing decoder.
@ -487,7 +489,7 @@ var _ = Describe("processor.acquire", func() {
It("oversized read: a resolved image larger than the cap fails without writing state", func() { It("oversized read: a resolved image larger than the cap fails without writing state", func() {
tmpDir := GinkgoT().TempDir() tmpDir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(tmpDir) }) conf.Server.DataFolder = conf.NewDir(tmpDir)
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg") imgPath := filepath.Join(tmpDir, "artwork", "radio", "big_test.jpg")
f, err := os.Create(imgPath) f, err := os.Create(imgPath)
@ -641,9 +643,12 @@ var _ = Describe("makeThumbnail", func() {
}) })
var _ = Describe("maxImageBytes", func() { var _ = Describe("maxImageBytes", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("reads MaxImageSize from config", func() { It("reads MaxImageSize from config", func() {
conf.Update(func(c *conf.Options) { c.MaxImageSize = "30MB" }) conf.Server.MaxImageSize = "30MB"
Expect(maxImageBytes()).To(Equal(int64(30_000_000))) Expect(maxImageBytes()).To(Equal(int64(30_000_000)))
}) })
}) })

View file

@ -48,7 +48,7 @@ func resizeImageData(ctx context.Context, ffm ffmpeg.FFmpeg, data []byte, size i
if isAnimatedGIF(data) { if isAnimatedGIF(data) {
if ffm.IsAvailable() { if ffm.IsAvailable() {
// Animated GIF: convert to animated WebP via ffmpeg (with optional resize) // Animated GIF: convert to animated WebP via ffmpeg (with optional resize)
r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server().CoverArtQuality) r, err := ffm.ConvertAnimatedImage(ctx, bytes.NewReader(data), size, conf.Server.CoverArtQuality)
if err == nil { if err == nil {
return r, 0, nil return r, 0, nil
} }
@ -117,12 +117,12 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
buf := bufPool.Get().(*bytes.Buffer) buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() buf.Reset()
if conf.Server().EnableWebPEncoding { if conf.Server.EnableWebPEncoding {
err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server().CoverArtQuality}) err = webp.Encode(buf, dst, webp.Options{Quality: conf.Server.CoverArtQuality})
} else if format == "png" || square { } else if format == "png" || square {
err = png.Encode(buf, dst) err = png.Encode(buf, dst)
} else { } else {
err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server().CoverArtQuality}) err = jpeg.Encode(buf, dst, &jpeg.Options{Quality: conf.Server.CoverArtQuality})
} }
if err != nil { if err != nil {
bufPool.Put(buf) bufPool.Put(buf)
@ -137,8 +137,8 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
// formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key // formatQualityTag folds the encoder config (WebP toggle + quality) into a cache-key
// fragment, so flipping either setting invalidates previously-encoded sized artwork. // fragment, so flipping either setting invalidates previously-encoded sized artwork.
func formatQualityTag() string { func formatQualityTag() string {
if conf.Server().EnableWebPEncoding { if conf.Server.EnableWebPEncoding {
return fmt.Sprintf("webp%d", conf.Server().CoverArtQuality) return fmt.Sprintf("webp%d", conf.Server.CoverArtQuality)
} }
return fmt.Sprintf("q%d", conf.Server().CoverArtQuality) return fmt.Sprintf("q%d", conf.Server.CoverArtQuality)
} }

View file

@ -125,11 +125,11 @@ func Explainable(kind model.Kind) bool {
func MayFetchExternal(kind model.Kind) bool { func MayFetchExternal(kind model.Kind) bool {
switch kind { switch kind {
case model.KindArtistArtwork: case model.KindArtistArtwork:
return chainFetchesExternal(conf.Server().ArtistArtPriority) return chainFetchesExternal(conf.Server.ArtistArtPriority)
case model.KindAlbumArtwork: case model.KindAlbumArtwork:
return chainFetchesExternal(conf.Server().CoverArtPriority) return chainFetchesExternal(conf.Server.CoverArtPriority)
case model.KindPlaylistArtwork: case model.KindPlaylistArtwork:
return conf.Server().EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server().CoverArtPriority) return conf.Server.EnableM3UExternalAlbumArt || chainFetchesExternal(conf.Server.CoverArtPriority)
default: default:
return false return false
} }
@ -160,10 +160,10 @@ func ExternalLookupsPerItem(kind model.Kind, agents ImageAgentCount) int64 {
return int64(max(agents.Album, 1)) return int64(max(agents.Album, 1))
case model.KindPlaylistArtwork: case model.KindPlaylistArtwork:
var n int64 var n int64
if conf.Server().EnableM3UExternalAlbumArt { if conf.Server.EnableM3UExternalAlbumArt {
n++ n++
} }
if chainFetchesExternal(conf.Server().CoverArtPriority) { if chainFetchesExternal(conf.Server.CoverArtPriority) {
n += PlaylistGridSamples * int64(max(agents.Album, 1)) n += PlaylistGridSamples * int64(max(agents.Album, 1))
} }
return n return n
@ -196,7 +196,7 @@ func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io
return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar) return fetchArtistImage(ctx, r.ext.agents, r.ext.gate, ar)
} }
// resolveAlbum walks conf.Server().CoverArtPriority over the folder, embedded and external sources. // resolveAlbum walks conf.Server.CoverArtPriority over the folder, embedded and external sources.
func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) { func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution, error) {
al, err := r.ds.Album().Get(ctx, albumID) al, err := r.ds.Album().Get(ctx, albumID)
if err != nil { if err != nil {
@ -212,7 +212,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
} }
chain := chainState{trace: traceFrom(ctx)} chain := chainState{trace: traceFrom(ctx)}
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server().CoverArtPriority), ",") { for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.CoverArtPriority), ",") {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
if pattern == "" { if pattern == "" {
continue continue
@ -241,7 +241,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
return chain.exhausted(), nil return chain.exhausted(), nil
} }
// resolveArtist tries the uploaded image first, then walks conf.Server().ArtistArtPriority. // resolveArtist tries the uploaded image first, then walks conf.Server.ArtistArtPriority.
func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) { func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resolution, error) {
ar, err := r.ds.Artist().Get(ctx, artistID) ar, err := r.ds.Artist().Get(ctx, artistID)
if err != nil { if err != nil {
@ -279,7 +279,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
} }
} }
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server().ArtistArtPriority), ",") { for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
if pattern == "" { if pattern == "" {
continue continue
@ -362,7 +362,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
// The remote fetch and the generated grid are worker-only; a request must do neither. // The remote fetch and the generated grid are worker-only; a request must do neither.
return resolution{}, nil return resolution{}, nil
} }
if remoteImg != nil && conf.Server().EnableM3UExternalAlbumArt { if remoteImg != nil && conf.Server.EnableM3UExternalAlbumArt {
sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) } sf := func() (io.ReadCloser, string, error) { return fromURL(ctx, remoteImg) }
if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok { if res, ok, err := resolveExternalStep(r.ext.gate, "m3u", sf); ok {
return res, nil return res, nil
@ -374,11 +374,8 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
} }
} }
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false) albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
if tracks == nil { GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
return resolution{}, fmt.Errorf("resolvePlaylist: could not load tracks for playlist %s", pl.ID)
}
albumIDs, err := tracks.GetAlbumIDs(ctx, model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
if err != nil { if err != nil {
return resolution{}, err return resolution{}, err
} }
@ -448,7 +445,7 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
} }
chain := chainState{trace: traceFrom(ctx)} chain := chainState{trace: traceFrom(ctx)}
switch { switch {
case !conf.Server().EnableMediaFileCoverArt: case !conf.Server.EnableMediaFileCoverArt:
chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off") chain.record("embedded", OutcomeSkipped, "EnableMediaFileCoverArt is off")
return resolution{}, nil return resolution{}, nil
case !mf.HasCoverArt: case !mf.HasCoverArt:
@ -466,7 +463,7 @@ func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution,
return chain.exhausted(), nil return chain.exhausted(), nil
} }
// resolveDisc walks conf.Server().DiscArtPriority. Disc artwork keeps no state row and is never // resolveDisc walks conf.Server.DiscArtPriority. Disc artwork keeps no state row and is never
// queued: the serving path reads it through on every request, so this only ever explains. // queued: the serving path reads it through on every request, so this only ever explains.
func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) { func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, error) {
dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id}) dr, err := newDiscArtworkReader(ctx, r.ds, model.ArtworkID{Kind: model.KindDiscArtwork, ID: id})
@ -474,7 +471,7 @@ func (r *resolver) resolveDisc(ctx context.Context, id string) (resolution, erro
return resolution{}, err return resolution{}, err
} }
chain := chainState{trace: traceFrom(ctx)} chain := chainState{trace: traceFrom(ctx)}
return dr.selectImage(ctx, r.ffmpeg, conf.Server().DiscArtPriority, &chain) return dr.selectImage(ctx, r.ffmpeg, conf.Server.DiscArtPriority, &chain)
} }
// resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a // resolveExternalStep runs a single external sourceFunc through the named gate. A not-found is a
@ -544,7 +541,7 @@ func resolveFolderFile(ctx context.Context, lib libraryView, imgFiles []string,
// Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed. // Basename-only on purpose: the chain climbs parent folders, so a token's prefix is not fixed.
func IsArtistImageFile(name string) bool { func IsArtistImageFile(name string) bool {
name = strings.ToLower(name) name = strings.ToLower(name)
for pattern := range strings.SplitSeq(strings.ToLower(conf.Server().ArtistArtPriority), ",") { for pattern := range strings.SplitSeq(strings.ToLower(conf.Server.ArtistArtPriority), ",") {
pattern = strings.TrimSpace(pattern) pattern = strings.TrimSpace(pattern)
if pattern == "" || pattern == externalCandidate || pattern == "image-folder" { if pattern == "" || pattern == externalCandidate || pattern == "image-folder" {
continue continue
@ -557,7 +554,7 @@ func IsArtistImageFile(name string) bool {
} }
func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) { func resolveArtistImageFolder(ar *model.Artist) (resolution, bool) {
folder := conf.Server().ArtistImageFolder folder := conf.Server.ArtistImageFolder
if folder == "" { if folder == "" {
return resolution{}, false return resolution{}, false
} }

View file

@ -13,6 +13,7 @@ import (
"sync/atomic" "sync/atomic"
"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/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
@ -21,22 +22,25 @@ import (
) )
var _ = Describe("IsArtistImageFile", func() { var _ = Describe("IsArtistImageFile", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("matches bare and album/-prefixed glob tokens, case-insensitively", func() { It("matches bare and album/-prefixed glob tokens, case-insensitively", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artistfolder.*, external" }) conf.Server.ArtistArtPriority = "artist.*, album/artistfolder.*, external"
Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue()) Expect(IsArtistImageFile("Artist.jpg")).To(BeTrue())
Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue()) Expect(IsArtistImageFile("artistfolder.png")).To(BeTrue())
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
}) })
It("matches a directory-bearing glob by its basename", func() { It("matches a directory-bearing glob by its basename", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "images/artist.*, external" }) conf.Server.ArtistArtPriority = "images/artist.*, external"
Expect(IsArtistImageFile("artist.jpg")).To(BeTrue()) Expect(IsArtistImageFile("artist.jpg")).To(BeTrue())
Expect(IsArtistImageFile("cover.jpg")).To(BeFalse()) Expect(IsArtistImageFile("cover.jpg")).To(BeFalse())
}) })
It("does not treat non-file tokens as globs", func() { It("does not treat non-file tokens as globs", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "image-folder, external" }) conf.Server.ArtistArtPriority = "image-folder, external"
Expect(IsArtistImageFile("image-folder")).To(BeFalse()) Expect(IsArtistImageFile("image-folder")).To(BeFalse())
Expect(IsArtistImageFile("external")).To(BeFalse()) Expect(IsArtistImageFile("external")).To(BeFalse())
}) })
@ -54,6 +58,7 @@ var _ = Describe("resolveItem", 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()
@ -79,7 +84,7 @@ var _ = Describe("resolveItem", func() {
Describe("media file", func() { Describe("media file", func() {
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = true }) conf.Server.EnableMediaFileCoverArt = true
ds.MockedMediaFile = tests.CreateMockMediaFileRepo() ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
}) })
@ -110,7 +115,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("resolves absent when media file cover art is disabled", func() { It("resolves absent when media file cover art is disabled", func() {
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = false }) conf.Server.EnableMediaFileCoverArt = false
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, {ID: "mf3", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
}) })
@ -129,7 +134,7 @@ var _ = Describe("resolveItem", func() {
Describe("album", func() { Describe("album", func() {
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.jpg, embedded" }) conf.Server.CoverArtPriority = "cover.jpg, embedded"
ds.MockedAlbum = tests.CreateMockAlbumRepo() ds.MockedAlbum = tests.CreateMockAlbumRepo()
}) })
@ -168,7 +173,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("sets extErr when the external source errors without being not-found", func() { It("sets extErr when the external source errors without being not-found", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al3", Name: "Album"}, {ID: "al3", Name: "Album"},
}) })
@ -181,7 +186,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("does not set extErr when the external source reports not-found", func() { It("does not set extErr when the external source reports not-found", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al4", Name: "Album"}, {ID: "al4", Name: "Album"},
}) })
@ -194,7 +199,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("carries extErr onto a fallback folder hit after a transient external failure", func() { It("carries extErr onto a fallback folder hit after a transient external failure", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external, cover.jpg" }) conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"}, ImageFiles: []string{"cover.jpg"},
@ -213,7 +218,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() { It("does not carry extErr onto a fallback folder hit after a definitive external not-found", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external, cover.jpg" }) conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"}, ImageFiles: []string{"cover.jpg"},
@ -232,7 +237,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("routes the external step through the injected gate, keyed by agent name", func() { It("routes the external step through the injected gate, keyed by agent name", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "al5", Name: "Album"}, {ID: "al5", Name: "Album"},
}) })
@ -253,7 +258,7 @@ var _ = Describe("resolveItem", func() {
Describe("artist", func() { Describe("artist", func() {
It("resolves the uploaded image before any priority chain lookup", func() { It("resolves the uploaded image before any priority chain lookup", func() {
tmpDir := GinkgoT().TempDir() tmpDir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(tmpDir) }) conf.Server.DataFolder = conf.NewDir(tmpDir)
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed()) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "artist"), 0755)).To(Succeed())
imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg") imgPath := filepath.Join(tmpDir, "artwork", "artist", "ar1_test.jpg")
Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed()) Expect(os.WriteFile(imgPath, []byte("uploaded artist image"), 0600)).To(Succeed())
@ -271,7 +276,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("falls through to the ArtistArtPriority chain when there is no upload", func() { It("falls through to the ArtistArtPriority chain when there is no upload", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "album/artist.*" }) conf.Server.ArtistArtPriority = "album/artist.*"
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
LibraryPath: testFileLibPath(repoRoot), LibraryPath: testFileLibPath(repoRoot),
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
@ -294,7 +299,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("sets extErr when the external source errors without being not-found", func() { It("sets extErr when the external source errors without being not-found", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "external" }) conf.Server.ArtistArtPriority = "external"
artistRepo := tests.CreateMockArtistRepo() artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}}) artistRepo.SetData(model.Artists{{ID: "ar3", Name: "Artist"}})
ds.MockedArtist = artistRepo ds.MockedArtist = artistRepo
@ -307,7 +312,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("does not set extErr when the external source reports not-found", func() { It("does not set extErr when the external source reports not-found", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "external" }) conf.Server.ArtistArtPriority = "external"
artistRepo := tests.CreateMockArtistRepo() artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}}) artistRepo.SetData(model.Artists{{ID: "ar4", Name: "Artist"}})
ds.MockedArtist = artistRepo ds.MockedArtist = artistRepo
@ -320,7 +325,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("routes the external step through the injected gate, keyed by agent name", func() { It("routes the external step through the injected gate, keyed by agent name", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "external" }) conf.Server.ArtistArtPriority = "external"
artistRepo := tests.CreateMockArtistRepo() artistRepo := tests.CreateMockArtistRepo()
artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}}) artistRepo.SetData(model.Artists{{ID: "ar5", Name: "Artist"}})
ds.MockedArtist = artistRepo ds.MockedArtist = artistRepo
@ -341,7 +346,7 @@ var _ = Describe("resolveItem", func() {
Describe("radio", func() { Describe("radio", func() {
It("yields an empty resolution when there is no uploaded image", func() { It("yields an empty resolution when there is no uploaded image", func() {
tmpDir := GinkgoT().TempDir() tmpDir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(tmpDir) }) conf.Server.DataFolder = conf.NewDir(tmpDir)
radioRepo := tests.CreateMockedRadioRepo() radioRepo := tests.CreateMockedRadioRepo()
radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}} radioRepo.Data = map[string]*model.Radio{"ra1": {ID: "ra1", Name: "Radio"}}
@ -354,7 +359,7 @@ var _ = Describe("resolveItem", func() {
It("resolves the uploaded image when set", func() { It("resolves the uploaded image when set", func() {
tmpDir := GinkgoT().TempDir() tmpDir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(tmpDir) }) conf.Server.DataFolder = conf.NewDir(tmpDir)
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed()) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "radio"), 0755)).To(Succeed())
imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg") imgPath := filepath.Join(tmpDir, "artwork", "radio", "ra2_test.jpg")
Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed())
@ -374,7 +379,7 @@ var _ = Describe("resolveItem", func() {
Describe("playlist", func() { Describe("playlist", func() {
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.jpg" }) conf.Server.CoverArtPriority = "cover.jpg"
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"}, ImageFiles: []string{"cover.jpg"},
@ -417,10 +422,8 @@ var _ = Describe("resolveItem", func() {
// The grid samples album art through the full album chain, so a playlist reaches the // The grid samples album art through the full album chain, so a playlist reaches the
// network even with the m3u fetch off. // network even with the m3u fetch off.
It("calls the album image agents for its grid tiles when m3u art is disabled", func() { It("calls the album image agents for its grid tiles when m3u art is disabled", func() {
conf.Update(func(c *conf.Options) { conf.Server.EnableM3UExternalAlbumArt = false
c.EnableM3UExternalAlbumArt = false conf.Server.CoverArtPriority = "external"
c.CoverArtPriority = "external"
})
folderRepo.result = nil folderRepo.result = nil
plRepo := tests.CreateMockPlaylistRepo() plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}}) plRepo.SetData(model.Playlists{{ID: "plgrid", Name: "Playlist"}})
@ -440,7 +443,7 @@ var _ = Describe("resolveItem", func() {
It("resolves the uploaded image before the generated grid", func() { It("resolves the uploaded image before the generated grid", func() {
tmpDir := GinkgoT().TempDir() tmpDir := GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(tmpDir) }) conf.Server.DataFolder = conf.NewDir(tmpDir)
Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed()) Expect(os.MkdirAll(filepath.Join(tmpDir, "artwork", "playlist"), 0755)).To(Succeed())
imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg") imgPath := filepath.Join(tmpDir, "artwork", "playlist", "plu_test.jpg")
Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed()) Expect(os.WriteFile(imgPath, []byte("uploaded playlist image"), 0600)).To(Succeed())
@ -513,7 +516,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("routes ExternalImageURL through extGate and sets extError on transient failure", func() { It("routes ExternalImageURL through extGate and sets extError on transient failure", func() {
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }) conf.Server.EnableM3UExternalAlbumArt = true
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
plRepo := tests.CreateMockPlaylistRepo() plRepo := tests.CreateMockPlaylistRepo()
@ -535,7 +538,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("records the m3u failure in the trace even when album sampling adds its own steps", func() { It("records the m3u failure in the trace even when album sampling adds its own steps", func() {
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }) conf.Server.EnableM3UExternalAlbumArt = true
folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry folderRepo.result = nil // the sampled album yields no tile, so the m3u failure is what forced the retry
plRepo := tests.CreateMockPlaylistRepo() plRepo := tests.CreateMockPlaylistRepo()
@ -580,7 +583,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() { It("treats an ExternalImageURL 404 as a definitive miss and falls through to the grid", func() {
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }) conf.Server.EnableM3UExternalAlbumArt = true
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
})) }))
@ -601,7 +604,7 @@ var _ = Describe("resolveItem", func() {
// A local resolver holds no agents: reaching the external branch would panic, not degrade. // A local resolver holds no agents: reaching the external branch would panic, not degrade.
It("skips the external step instead of dereferencing absent agents", func() { It("skips the external step instead of dereferencing absent agents", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum = tests.CreateMockAlbumRepo() ds.MockedAlbum = tests.CreateMockAlbumRepo()
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
@ -613,7 +616,7 @@ var _ = Describe("resolveItem", func() {
// The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously. // The worker resolving the same playlist is asserted alongside, so this cannot pass vacuously.
It("resolves a playlist locally without fetching remotely or building the grid", func() { It("resolves a playlist locally without fetching remotely or building the grid", func() {
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }) conf.Server.EnableM3UExternalAlbumArt = true
var hits atomic.Int32 var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1) hits.Add(1)
@ -641,7 +644,7 @@ var _ = Describe("resolveItem", func() {
}) })
It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() { It("treats an ExternalImageURL 500 as a transient failure and sets extError", func() {
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }) conf.Server.EnableM3UExternalAlbumArt = true
folderRepo.result = nil // no grid tiles, so the external failure is what surfaces folderRepo.result = nil // no grid tiles, so the external failure is what surfaces
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -704,16 +707,6 @@ var _ = Describe("resolveItem", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{})) Expect(res).To(Equal(resolution{}))
}) })
It("returns an error when the playlist tracks cannot be loaded", func() {
plRepo := tests.CreateMockPlaylistRepo()
plRepo.SetData(model.Playlists{{ID: "pl4", Name: "Playlist"}})
ds.MockedPlaylist = plRepo
res, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "pl4"})
Expect(err).To(HaveOccurred())
Expect(res).To(Equal(resolution{}))
})
}) })
}) })
@ -749,18 +742,15 @@ var _ = Describe("Explainable", func() {
var _ = Describe("MayFetchExternal", func() { var _ = Describe("MayFetchExternal", func() {
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { DeferCleanup(configtest.SetupConfig())
c.CoverArtPriority = "cover.*, embedded" conf.Server.CoverArtPriority = "cover.*, embedded"
c.ArtistArtPriority = "artist.*" conf.Server.ArtistArtPriority = "artist.*"
c.EnableM3UExternalAlbumArt = false conf.Server.EnableM3UExternalAlbumArt = false
})
}) })
It("is true for the kinds whose chain includes the external candidate", func() { It("is true for the kinds whose chain includes the external candidate", func() {
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "cover.*, external"
c.CoverArtPriority = "cover.*, external" conf.Server.ArtistArtPriority = "artist.*, external"
c.ArtistArtPriority = "artist.*, external"
})
Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue()) Expect(MayFetchExternal(model.KindAlbumArtwork)).To(BeTrue())
Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue()) Expect(MayFetchExternal(model.KindArtistArtwork)).To(BeTrue())
}) })
@ -771,12 +761,12 @@ var _ = Describe("MayFetchExternal", func() {
}) })
It("is true for playlists when the m3u image fetch is enabled", func() { It("is true for playlists when the m3u image fetch is enabled", func() {
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }) conf.Server.EnableM3UExternalAlbumArt = true
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
}) })
It("is true for playlists whose grid tiles resolve through an external album chain", func() { It("is true for playlists whose grid tiles resolve through an external album chain", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*, external" }) conf.Server.CoverArtPriority = "cover.*, external"
Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue()) Expect(MayFetchExternal(model.KindPlaylistArtwork)).To(BeTrue())
}) })
@ -785,11 +775,9 @@ var _ = Describe("MayFetchExternal", func() {
}) })
It("is false for the kinds that only read local files", func() { It("is false for the kinds that only read local files", func() {
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "external"
c.CoverArtPriority = "external" conf.Server.ArtistArtPriority = "external"
c.ArtistArtPriority = "external" conf.Server.EnableM3UExternalAlbumArt = true
c.EnableM3UExternalAlbumArt = true
})
Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse()) Expect(MayFetchExternal(model.KindRadioArtwork)).To(BeFalse())
Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse()) Expect(MayFetchExternal(model.KindMediaFileArtwork)).To(BeFalse())
}) })
@ -799,11 +787,10 @@ var _ = Describe("ExternalLookupsPerItem", func() {
count := ImageAgentCount{Artist: 3, Album: 2} count := ImageAgentCount{Artist: 3, Album: 2}
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("bills one call per agent, since the walk only stops early on a hit", func() { It("bills one call per agent, since the walk only stops early on a hit", func() {
@ -817,16 +804,14 @@ var _ = Describe("ExternalLookupsPerItem", func() {
}) })
It("adds the m3u image fetch on top of the grid", func() { It("adds the m3u image fetch on top of the grid", func() {
conf.Update(func(c *conf.Options) { c.EnableM3UExternalAlbumArt = true }) conf.Server.EnableM3UExternalAlbumArt = true
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)). Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).
To(Equal(int64(PlaylistGridSamples)*2 + 1)) To(Equal(int64(PlaylistGridSamples)*2 + 1))
}) })
It("bills only the m3u fetch when the album chain stays local", func() { It("bills only the m3u fetch when the album chain stays local", func() {
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "cover.*"
c.CoverArtPriority = "cover.*" conf.Server.EnableM3UExternalAlbumArt = true
c.EnableM3UExternalAlbumArt = true
})
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1))) Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(Equal(int64(1)))
}) })
@ -839,10 +824,8 @@ var _ = Describe("ExternalLookupsPerItem", func() {
}) })
It("is zero whenever the kind reaches no agent at all", func() { It("is zero whenever the kind reaches no agent at all", func() {
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "cover.*"
c.CoverArtPriority = "cover.*" conf.Server.ArtistArtPriority = "artist.*"
c.ArtistArtPriority = "artist.*"
})
Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero()) Expect(ExternalLookupsPerItem(model.KindArtistArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero()) Expect(ExternalLookupsPerItem(model.KindAlbumArtwork, count)).To(BeZero())
Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero()) Expect(ExternalLookupsPerItem(model.KindPlaylistArtwork, count)).To(BeZero())

View file

@ -10,6 +10,7 @@ import (
"strings" "strings"
"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"
@ -211,7 +212,8 @@ var _ = Describe("resolveAlbum tracing", func() {
) )
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.jpg, embedded" }) DeferCleanup(configtest.SetupConfig())
conf.Server.CoverArtPriority = "cover.jpg, embedded"
repoRoot, err := os.Getwd() repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{} libRepo := &tests.MockLibraryRepo{}
@ -264,7 +266,7 @@ var _ = Describe("resolveAlbum tracing", func() {
}) })
It("ignores an empty priority token", func() { It("ignores an empty priority token", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.jpg," }) conf.Server.CoverArtPriority = "cover.jpg,"
albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}}) albumRepo.SetData(model.Albums{{ID: "al2", Name: "Album", FolderIDs: []string{"f1"}}})
_, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"}) _, err := newResolver(ds, ag, ffm, nil).resolve(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
@ -296,10 +298,9 @@ var _ = Describe("resolveArtist tracing", func() {
} }
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { DeferCleanup(configtest.SetupConfig())
c.DataFolder = conf.NewDir(GinkgoT().TempDir()) conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
c.ArtistArtPriority = "album/artist.*" conf.Server.ArtistArtPriority = "album/artist.*"
})
var err error var err error
repoRoot, err = os.Getwd() repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -371,7 +372,7 @@ var _ = Describe("resolveArtist tracing", func() {
}) })
It("records why an artist folder pattern was skipped", func() { It("records why an artist folder pattern was skipped", func() {
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*" }) conf.Server.ArtistArtPriority = "artist.*"
artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}}) artistRepo.SetData(model.Artists{{ID: "ar6", Name: "Artist"}})
albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}} albumRepo.All = model.Albums{{ID: "al10", Name: "Album", LibraryID: 0, FolderIDs: []string{"f1"}}}
@ -412,11 +413,10 @@ var _ = Describe("NewTracingResolver", func() {
) )
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { DeferCleanup(configtest.SetupConfig())
c.DataFolder = conf.NewDir(GinkgoT().TempDir()) conf.Server.DataFolder = conf.NewDir(GinkgoT().TempDir())
c.CoverArtPriority = "external, embedded" conf.Server.CoverArtPriority = "external, embedded"
c.ArtistArtPriority = "external" conf.Server.ArtistArtPriority = "external"
})
repoRoot, err := os.Getwd() repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{} libRepo := &tests.MockLibraryRepo{}
@ -487,7 +487,7 @@ var _ = Describe("NewTracingResolver", func() {
}) })
It("closes the reader it does not hand back", func() { It("closes the reader it does not hand back", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "embedded" }) conf.Server.CoverArtPriority = "embedded"
ffm = tests.NewMockFFmpeg("fake image bytes") ffm = tests.NewMockFFmpeg("fake image bytes")
albumRepo.SetData(model.Albums{{ albumRepo.SetData(model.Albums{{
ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"}, ID: "al2", Name: "Album", EmbedArtPath: "tests/fixtures/artist/an-album/no-such-file.mp3", FolderIDs: []string{"f1"},
@ -503,11 +503,9 @@ var _ = Describe("NewTracingResolver", func() {
// Serving falls back disc -> album and track -> disc -> album. The resolver does not, but // Serving falls back disc -> album and track -> disc -> album. The resolver does not, but
// if it ever did, an explain without --live would start calling providers uninvited. // if it ever did, an explain without --live would start calling providers uninvited.
It("cannot reach a provider without live, whatever the chain does", func() { It("cannot reach a provider without live, whatever the chain does", func() {
conf.Update(func(c *conf.Options) { conf.Server.DiscArtPriority = "external, cover.*"
c.DiscArtPriority = "external, cover.*" conf.Server.CoverArtPriority = "external, cover.*"
c.CoverArtPriority = "external, cover.*" conf.Server.EnableMediaFileCoverArt = true
c.EnableMediaFileCoverArt = true
})
mfRepo := tests.CreateMockMediaFileRepo() mfRepo := tests.CreateMockMediaFileRepo()
mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true, mfRepo.SetData(model.MediaFiles{{ID: "mf1", LibraryID: 0, HasCoverArt: true,
Path: "tests/fixtures/artist/an-album/test.mp3"}}) Path: "tests/fixtures/artist/an-album/test.mp3"}})
@ -541,6 +539,7 @@ var _ = Describe("resolveDisc tracing", func() {
) )
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
repoRoot, err := os.Getwd() repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{} libRepo := &tests.MockLibraryRepo{}
@ -562,7 +561,7 @@ var _ = Describe("resolveDisc tracing", func() {
}) })
It("accounts for every configured entry, including the ones that map to no source", func() { It("accounts for every configured entry, including the ones that map to no source", func() {
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "external, discsubtitle, cover.jpg" }) conf.Server.DiscArtPriority = "external, discsubtitle, cover.jpg"
res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2)) res, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, model.DiscArtworkID("al1", 2))
@ -576,7 +575,7 @@ var _ = Describe("resolveDisc tracing", func() {
}) })
It("records the entry that won and stops there", func() { It("records the entry that won and stops there", func() {
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "disc*.*, cover.jpg, embedded" }) conf.Server.DiscArtPriority = "disc*.*, cover.jpg, embedded"
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"}, ImageFiles: []string{"cover.jpg"},
@ -598,7 +597,7 @@ var _ = Describe("resolveDisc tracing", func() {
}) })
It("reports an unparseable disc id rather than explaining another disc", func() { It("reports an unparseable disc id rather than explaining another disc", func() {
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "cover.jpg" }) conf.Server.DiscArtPriority = "cover.jpg"
_, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1") _, err := newResolver(ds, nil, ffm, nil).resolveDisc(ctx, "al1")
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
}) })
@ -614,7 +613,8 @@ var _ = Describe("resolveMediaFile tracing", func() {
) )
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = true }) DeferCleanup(configtest.SetupConfig())
conf.Server.EnableMediaFileCoverArt = true
repoRoot, err := os.Getwd() repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
libRepo := &tests.MockLibraryRepo{} libRepo := &tests.MockLibraryRepo{}
@ -630,7 +630,7 @@ var _ = Describe("resolveMediaFile tracing", func() {
}) })
It("separates a disabled setting from a track with nothing embedded", func() { It("separates a disabled setting from a track with nothing embedded", func() {
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = false }) conf.Server.EnableMediaFileCoverArt = false
_, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1") _, err := newResolver(ds, nil, ffm, nil).resolveMediaFile(ctx, "mf1")

View file

@ -17,7 +17,7 @@ import (
// MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default. // MaxImageUploadSize returns the configured max upload size in bytes, or the built-in default.
func MaxImageUploadSize() int64 { func MaxImageUploadSize() int64 {
return parseSize(conf.Server().MaxImageUploadSize, consts.DefaultMaxImageUploadSize) return parseSize(conf.Server.MaxImageUploadSize, consts.DefaultMaxImageUploadSize)
} }
func parseSize(value, fallback string) int64 { func parseSize(value, fallback string) int64 {

View file

@ -7,6 +7,7 @@ import (
"strings" "strings"
"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/tests" "github.com/navidrome/navidrome/tests"
@ -23,8 +24,9 @@ var _ = Describe("Uploader", func() {
BeforeEach(func() { BeforeEach(func() {
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
tmpDir = GinkgoT().TempDir() tmpDir = GinkgoT().TempDir()
conf.Update(func(c *conf.Options) { c.DataFolder = conf.NewDir(tmpDir) }) conf.Server.DataFolder = conf.NewDir(tmpDir)
artRepo = tests.CreateMockArtworkRepo() artRepo = tests.CreateMockArtworkRepo()
queueRepo = tests.CreateMockArtworkQueueRepo() queueRepo = tests.CreateMockArtworkQueueRepo()
ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo} ds := &tests.MockDataStore{MockedArtwork: artRepo, MockedArtworkQueue: queueRepo}
@ -139,24 +141,27 @@ var _ = Describe("Uploader", func() {
}) })
var _ = Describe("MaxImageUploadSize", func() { var _ = Describe("MaxImageUploadSize", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
})
It("returns the configured size when valid", func() { It("returns the configured size when valid", func() {
conf.Update(func(c *conf.Options) { c.MaxImageUploadSize = "20MB" }) conf.Server.MaxImageUploadSize = "20MB"
Expect(MaxImageUploadSize()).To(Equal(int64(20_000_000))) Expect(MaxImageUploadSize()).To(Equal(int64(20_000_000)))
}) })
It("returns the default size when config is empty", func() { It("returns the default size when config is empty", func() {
conf.Update(func(c *conf.Options) { c.MaxImageUploadSize = "" }) conf.Server.MaxImageUploadSize = ""
Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000))) Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000)))
}) })
It("returns the default size when config is invalid", func() { It("returns the default size when config is invalid", func() {
conf.Update(func(c *conf.Options) { c.MaxImageUploadSize = "not-a-size" }) conf.Server.MaxImageUploadSize = "not-a-size"
Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000))) Expect(MaxImageUploadSize()).To(Equal(int64(10_000_000)))
}) })
It("parses raw byte values", func() { It("parses raw byte values", func() {
conf.Update(func(c *conf.Options) { c.MaxImageUploadSize = "52428800" }) conf.Server.MaxImageUploadSize = "52428800"
Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800))) Expect(MaxImageUploadSize()).To(Equal(int64(52_428_800)))
}) })
}) })

View file

@ -4,11 +4,9 @@ import (
"bytes" "bytes"
"cmp" "cmp"
"context" "context"
"fmt"
"io" "io"
"math" "math"
"math/rand/v2" "math/rand/v2"
"runtime/debug"
"sync" "sync"
"time" "time"
@ -74,9 +72,9 @@ func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg
// rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk. // rate-limit permit, so a sleeping lookup would crowd out a cover sitting on disk.
func newDrainPools() []*drainPool { func newDrainPools() []*drainPool {
budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive budget := conf.MaxOpenConns() // floored at 4, so both remainders below stay positive
local := min(max(1, conf.Server().DevArtworkWorkerConcurrency), budget-1) local := min(max(1, conf.Server.DevArtworkWorkerConcurrency), budget-1)
// More external slots than the rate allows would only sleep in the limiter. // More external slots than the rate allows would only sleep in the limiter.
external := min(max(2, 2*conf.Server().DevArtworkExternalMaxRPS), budget-local) external := min(max(2, 2*conf.Server.DevArtworkExternalMaxRPS), budget-local)
return []*drainPool{ return []*drainPool{
{name: "local", kinds: localDrainKinds, concurrency: local}, {name: "local", kinds: localDrainKinds, concurrency: local},
{name: "external", kinds: externalDrainKinds, concurrency: external}, {name: "external", kinds: externalDrainKinds, concurrency: external},
@ -246,7 +244,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary) item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
trace := &ChainTrace{} trace := &ChainTrace{}
ctx = withTrace(ctx, trace) ctx = withTrace(ctx, trace)
out, got, retryIn := w.safeAcquire(ctx, item) out, got, retryIn := w.proc.acquire(ctx, item)
queue := w.proc.ds.ArtworkQueue() queue := w.proc.ds.ArtworkQueue()
switch out { switch out {
@ -288,20 +286,6 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
return out, got return out, got
} }
// safeAcquire turns a panic into a failed attempt: the drain runs on a bare goroutine, so an
// unrecovered panic would crash the server, and the still-queued row would crash it again on restart.
func (w *Worker) safeAcquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
defer func() {
if r := recover(); r != nil {
log.Error(ctx, "Artwork: Panic while processing item", "kind", item.ItemKind, "id", item.ItemID,
"imageType", item.ImageType, "attempts", item.Attempts, "panic", r, "stack", string(debug.Stack()))
traceStage(ctx, "panic", fmt.Errorf("%v", r))
out, got, retryIn = outcomeFailed, nil, 0
}
}()
return w.proc.acquire(ctx, item)
}
// recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item // recordGiveUp keeps the last failure on the state row after the queue row is deleted. An item
// that never resolved has no row to update, and creating one would settle it absent. // that never resolved has no row to update, and creating one would settle it absent.
func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) { func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem, trace string) {
@ -326,7 +310,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
// precache warms the resize cache at the UI cover size from the bytes just acquired, so the // precache warms the resize cache at the UI cover size from the bytes just acquired, so the
// first UI request hits without re-reading the rows or the file. // first UI request hits without re-reading the rows or the file.
func (w *Worker) precache(ctx context.Context, got *acquired) { func (w *Worker) precache(ctx context.Context, got *acquired) {
if !conf.Server().EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) { if !conf.Server.EnableArtworkPrecache || w.cache == nil || w.cache.Disabled(ctx) {
return return
} }
precacheStart := time.Now() precacheStart := time.Now()
@ -334,7 +318,7 @@ func (w *Worker) precache(ctx context.Context, got *acquired) {
// warms a key nothing reads. // warms a key nothing reads.
item := &resizedItem{ item := &resizedItem{
hash: got.ia.Hash, hash: got.ia.Hash,
size: conf.Server().UICoverArtSize, size: conf.Server.UICoverArtSize,
square: true, square: true,
ffmpeg: w.ffmpeg, ffmpeg: w.ffmpeg,
open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil }, open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(got.data)), nil },
@ -347,7 +331,7 @@ func (w *Worker) precache(ctx context.Context, got *acquired) {
_, _ = io.Copy(io.Discard, stream) _, _ = io.Copy(io.Discard, stream)
_ = stream.Close() _ = stream.Close()
log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID, log.Trace(ctx, "Artwork: Precached UI size", "kind", got.ia.ItemKind, "id", got.ia.ItemID,
"size", conf.Server().UICoverArtSize, "elapsed", time.Since(precacheStart)) "size", conf.Server.UICoverArtSize, "elapsed", time.Since(precacheStart))
} }
// backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4]. // backoffFor returns min(5s×4^n, giveUpAfter) scaled by (1+jitter), with jitter in [-0.4, 0.4].

View file

@ -9,6 +9,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/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/tests"
@ -30,6 +31,7 @@ var _ = Describe("Worker soak", func() {
if testing.Short() { if testing.Short() {
Skip("skipping soak test in short mode") Skip("skipping soak test in short mode")
} }
DeferCleanup(configtest.SetupConfig())
repoRoot, err := os.Getwd() repoRoot, err := os.Getwd()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -56,7 +58,7 @@ var _ = Describe("Worker soak", func() {
} }
store := NewImageStore(GinkgoT().TempDir()) store := NewImageStore(GinkgoT().TempDir())
proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)} proc := &processor{ds: ds, store: store, resolver: newResolver(ds, ag, ffm, nil)}
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.jpg, embedded" }) conf.Server.CoverArtPriority = "cover.jpg, embedded"
// Dangling refs mirror an entity deleted after being enqueued. // Dangling refs mirror an entity deleted after being enqueued.
items := []model.ArtworkQueueItem{ items := []model.ArtworkQueueItem{

View file

@ -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/model" "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/model/request"
@ -141,18 +142,6 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
return v.MockPlaylistRepo.Get(ctx, id) return v.MockPlaylistRepo.Get(ctx, id)
} }
type panickingAlbumRepo struct {
*tests.MockAlbumRepo
panicID string
}
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
if id == r.panicID {
panic("boom")
}
return r.MockAlbumRepo.Get(ctx, id)
}
func adminUserRepo() *tests.MockedUserRepo { func adminUserRepo() *tests.MockedUserRepo {
repo := tests.CreateMockUserRepo() repo := tests.CreateMockUserRepo()
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed()) Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
@ -178,10 +167,11 @@ var _ = Describe("Worker", func() {
BeforeEach(func() { BeforeEach(func() {
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
DeferCleanup(configtest.SetupConfig())
var err error var err error
repoRoot, err = os.Getwd() repoRoot, err = os.Getwd()
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
conf.Update(func(c *conf.Options) { c.CacheFolder = conf.NewDir(GinkgoT().TempDir()) }) conf.Server.CacheFolder = conf.NewDir(GinkgoT().TempDir())
folderRepo = &fakeFolderRepo{} folderRepo = &fakeFolderRepo{}
libRepo = &tests.MockLibraryRepo{} libRepo = &tests.MockLibraryRepo{}
@ -198,10 +188,8 @@ var _ = Describe("Worker", func() {
} }
ds.MockedAlbum = tests.CreateMockAlbumRepo() ds.MockedAlbum = tests.CreateMockAlbumRepo()
store = NewImageStore(GinkgoT().TempDir()) store = NewImageStore(GinkgoT().TempDir())
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "cover.jpg, embedded"
c.CoverArtPriority = "cover.jpg, embedded" conf.Server.DevArtworkExternalMaxRPS = 1000 // keep the limiter out of the way of behavior tests
c.DevArtworkExternalMaxRPS = 1000
}) // keep the limiter out of the way of behavior tests
broker = &fakeEventBroker{} broker = &fakeEventBroker{}
imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0, imgCache = &recordingCache{FileCache: cache.NewFileCache("WorkerTest", "100MB", "images", 0,
func(ctx context.Context, arg cache.Item) (io.Reader, error) { func(ctx context.Context, arg cache.Item) (io.Reader, error) {
@ -239,7 +227,7 @@ var _ = Describe("Worker", func() {
}) })
It("processes an mf queue item, writing state and storing embedded bytes", func() { It("processes an mf queue item, writing state and storing embedded bytes", func() {
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = true }) conf.Server.EnableMediaFileCoverArt = true
ds.MockedMediaFile = tests.CreateMockMediaFileRepo() ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true}, {ID: "mf1", LibraryID: 0, Path: "tests/fixtures/artist/an-album/test.mp3", HasCoverArt: true},
@ -272,7 +260,7 @@ var _ = Describe("Worker", func() {
}) })
It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() { It("reschedules a failed item via MarkFailed with a backed-off retry_at", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al4", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed()) Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
@ -290,38 +278,8 @@ var _ = Describe("Worker", func() {
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent") Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
}) })
It("fails an item that panics, without stopping the rest of the batch", func() {
folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"},
}}
albums := tests.CreateMockAlbumRepo()
albums.SetData(model.Albums{
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
})
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred())
Expect(n).To(Equal(2))
it := findQueued(queueRepo, "al", "alboom")
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
Expect(it.Attempts).To(Equal(1))
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
Expect(it.Trace).To(ContainSubstring("boom"))
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
Expect(err).ToNot(HaveOccurred())
Expect(ia.Source).To(Equal("folder"))
})
It("reschedules past the provider's requested delay when it exceeds the backoff", func() { It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
// Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at. // Well above backoff(0)'s jittered ceiling, so only the hint can produce this retry_at.
const askedFor = 90 * time.Minute const askedFor = 90 * time.Minute
@ -338,7 +296,7 @@ var _ = Describe("Worker", func() {
}) })
It("reschedules a found-stale item via MarkFailed while keeping its served state", func() { It("reschedules a found-stale item via MarkFailed while keeping its served state", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external, cover.jpg" }) conf.Server.CoverArtPriority = "external, cover.jpg"
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"}, ImageFiles: []string{"cover.jpg"},
@ -394,7 +352,7 @@ var _ = Describe("Worker", func() {
}) })
It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() { It("keeps a fresh re-enqueue ahead of a stale failure backoff", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al8", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo} racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
@ -415,7 +373,7 @@ var _ = Describe("Worker", func() {
}) })
It("gives up and settles absent once the retry budget is exhausted", func() { It("gives up and settles absent once the retry budget is exhausted", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al9", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache) w = NewWorker(ds, store, ag, ffm, broker, imgCache)
@ -434,7 +392,7 @@ var _ = Describe("Worker", func() {
}) })
It("keeps already-served art when the retry budget is exhausted", func() { It("keeps already-served art when the retry budget is exhausted", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al10", Name: "Album"}})
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary, ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
@ -456,7 +414,7 @@ var _ = Describe("Worker", func() {
}) })
It("records on the queue row why the last attempt failed", func() { It("records on the queue row why the last attempt failed", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al11", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache) w = NewWorker(ds, store, ag, ffm, broker, imgCache)
@ -477,7 +435,7 @@ var _ = Describe("Worker", func() {
// The give-up path settles absent before recording, so the row exists by the time the // The give-up path settles absent before recording, so the row exists by the time the
// failure is written. Recording first would silently lose it for every unresolved item. // failure is written. Recording first would silently lose it for every unresolved item.
It("keeps the failure for an item that never resolved at all", func() { It("keeps the failure for an item that never resolved at all", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al13", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
w = NewWorker(ds, store, ag, ffm, broker, imgCache) w = NewWorker(ds, store, ag, ffm, broker, imgCache)
@ -494,7 +452,7 @@ var _ = Describe("Worker", func() {
}) })
It("keeps the failure on the state row after the queue row is deleted", func() { It("keeps the failure on the state row after the queue row is deleted", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al12", Name: "Album"}})
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary, ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
@ -519,7 +477,7 @@ var _ = Describe("Worker", func() {
// Only a view enqueues a media file, and an absent row is exactly what stops a view from // Only a view enqueues a media file, and an absent row is exactly what stops a view from
// doing so: a transient read error would look permanent. // doing so: a transient read error would look permanent.
It("does not settle absent on exhaustion for a media file", func() { It("does not settle absent on exhaustion for a media file", func() {
conf.Update(func(c *conf.Options) { c.EnableMediaFileCoverArt = true }) conf.Server.EnableMediaFileCoverArt = true
ds.MockedMediaFile = tests.CreateMockMediaFileRepo() ds.MockedMediaFile = tests.CreateMockMediaFileRepo()
ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ ds.MockedMediaFile.(*tests.MockMediaFileRepo).SetData(model.MediaFiles{
{ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true}, {ID: "mfX", LibraryID: 0, Path: "tests/fixtures/artist/an-album/gone.mp3", HasCoverArt: true},
@ -620,7 +578,7 @@ var _ = Describe("Worker", func() {
) )
It("broadcasts a refresh when an item resolves to absent (removed cover)", func() { It("broadcasts a refresh when an item resolves to absent (removed cover)", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" }) // local-only; no folder image → absent conf.Server.CoverArtPriority = "cover.*" // local-only; no folder image → absent
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "al3", Name: "Artless"}})
folderRepo.result = nil folderRepo.result = nil
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed()) Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3", Priority: model.ArtworkPriorityScan})).To(Succeed())
@ -639,7 +597,7 @@ var _ = Describe("Worker", func() {
}) })
It("does not broadcast when no item is found", func() { It("does not broadcast when no item is found", func() {
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external" }) conf.Server.CoverArtPriority = "external"
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}}) ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: "alx", Name: "Album"}})
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")}) imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed()) Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
@ -775,14 +733,14 @@ var _ = Describe("Worker", func() {
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{ ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}}, {ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
}) })
conf.Update(func(c *conf.Options) { c.UICoverArtSize = 300 }) conf.Server.UICoverArtSize = 300
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan, ItemKind: "al", ItemID: "alpc", Priority: model.ArtworkPriorityScan,
})).To(Succeed()) })).To(Succeed())
}) })
It("warms the resize cache at the UI cover size after a found acquisition", func() { It("warms the resize cache at the UI cover size after a found acquisition", func() {
conf.Update(func(c *conf.Options) { c.EnableArtworkPrecache = true }) conf.Server.EnableArtworkPrecache = true
n, err := w.drain(ctx, 1) n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -793,7 +751,7 @@ var _ = Describe("Worker", func() {
}) })
It("skips warming when precache is disabled", func() { It("skips warming when precache is disabled", func() {
conf.Update(func(c *conf.Options) { c.EnableArtworkPrecache = false }) conf.Server.EnableArtworkPrecache = false
n, err := w.drain(ctx, 1) n, err := w.drain(ctx, 1)
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
@ -804,7 +762,7 @@ var _ = Describe("Worker", func() {
// It warms from the bytes acquisition already held, so no state row or store file is needed. // It warms from the bytes acquisition already held, so no state row or store file is needed.
It("warms from the acquired bytes without reading them back", func() { It("warms from the acquired bytes without reading them back", func() {
conf.Update(func(c *conf.Options) { c.EnableArtworkPrecache = true }) conf.Server.EnableArtworkPrecache = true
ia := &model.ItemArtwork{ ia := &model.ItemArtwork{
ItemKind: "al", ItemID: "unpersisted", ImageType: model.ImageTypePrimary, ItemKind: "al", ItemID: "unpersisted", ImageType: model.ImageTypePrimary,
Hash: "abcdef0123456789", UpdatedAt: time.Now(), Hash: "abcdef0123456789", UpdatedAt: time.Now(),
@ -844,10 +802,8 @@ var _ = Describe("Worker", func() {
// Artists resolve through a rate-limited agent that holds its slot while waiting, so one // Artists resolve through a rate-limited agent that holds its slot while waiting, so one
// shared pool would park every album behind them. // shared pool would park every album behind them.
It("resolves albums while artists are stuck on a slow agent", func() { It("resolves albums while artists are stuck on a slow agent", func() {
conf.Update(func(c *conf.Options) { conf.Server.CoverArtPriority = "cover.jpg"
c.CoverArtPriority = "cover.jpg" conf.Server.ArtistArtPriority = "external"
c.ArtistArtPriority = "external"
})
folderRepo.result = []model.Folder{{ folderRepo.result = []model.Folder{{
Path: "tests/fixtures/artist/an-album", Path: "tests/fixtures/artist/an-album",
ImageFiles: []string{"cover.jpg"}, ImageFiles: []string{"cover.jpg"},
@ -877,7 +833,7 @@ var _ = Describe("Worker", func() {
runCtx, cancel := context.WithCancel(ctx) runCtx, cancel := context.WithCancel(ctx)
done := make(chan struct{}) done := make(chan struct{})
go func() { defer close(done); _ = w.Run(runCtx) }() go func() { defer close(done); _ = w.Run(runCtx) }()
// Join Run so no pool goroutine outlives the spec. // Join Run: a leaked pool goroutine would race the config snapshot Ginkgo restores.
DeferCleanup(func() { DeferCleanup(func() {
cancel() cancel()
close(block) // unpark the blocked lookups so the pools can unwind close(block) // unpark the blocked lookups so the pools can unwind
@ -1002,6 +958,7 @@ var _ = Describe("Worker", func() {
}) })
It("does not leak goroutines after Run exits", func() { It("does not leak goroutines after Run exits", func() {
DeferCleanup(configtest.SetupConfig())
ignore := goleak.IgnoreCurrent() ignore := goleak.IgnoreCurrent()
DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) }) DeferCleanup(func() { goleak.VerifyNone(GinkgoT(), ignore) })

View file

@ -40,7 +40,7 @@ const (
func Init(ds model.DataStore) { func Init(ds model.DataStore) {
once.Do(func() { once.Do(func() {
ctx := context.TODO() ctx := context.TODO()
log.Info("Setting Session Timeout", "value", conf.Server().SessionTimeout) log.Info("Setting Session Timeout", "value", conf.Server.SessionTimeout)
TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil) TokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTSecretKey)), nil)
PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil) PublicTokenAuth = jwtauth.New("HS256", []byte(loadOrCreateSecret(ctx, ds, consts.JWTPublicSecretKey)), nil)
@ -108,7 +108,7 @@ func TouchToken(token jwt.Token) (string, error) {
} }
func TouchClaims(c Claims) (string, error) { func TouchClaims(c Claims) (string, error) {
c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server().SessionTimeout)) c = c.WithExpiresAt(time.Now().UTC().Add(conf.Server.SessionTimeout))
_, newToken, err := TokenAuth.Encode(c.ToMap()) _, newToken, err := TokenAuth.Encode(c.ToMap())
return newToken, err return newToken, err
} }
@ -197,7 +197,7 @@ func DecodeAndVerifyToken(tokenStr string) (jwt.Token, error) {
func getEncKey() []byte { func getEncKey() []byte {
key := cmp.Or( key := cmp.Or(
conf.Server().PasswordEncryptionKey, conf.Server.PasswordEncryptionKey,
consts.DefaultEncryptionKey, consts.DefaultEncryptionKey,
) )
sum := sha256.Sum256([]byte(key)) sum := sha256.Sum256([]byte(key))

View file

@ -5,7 +5,6 @@ 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/auth" "github.com/navidrome/navidrome/core/auth"
"github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/log"
@ -21,14 +20,12 @@ func TestAuth(t *testing.T) {
RunSpecs(t, "Auth Test Suite") RunSpecs(t, "Auth Test Suite")
} }
var _ = configtest.RestoreAfterEachSpec()
const ( const (
oneDay = 24 * time.Hour oneDay = 24 * time.Hour
) )
var _ = BeforeSuite(func() { var _ = BeforeSuite(func() {
conf.Update(func(c *conf.Options) { c.SessionTimeout = 2 * oneDay }) conf.Server.SessionTimeout = 2 * oneDay
}) })
var _ = Describe("Auth", func() { var _ = Describe("Auth", func() {

View file

@ -3,7 +3,6 @@ package core
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 TestCore(t *testing.T) {
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "Core Suite") RunSpecs(t, "Core Suite")
} }
var _ = configtest.RestoreAfterEachSpec()

View file

@ -3,7 +3,6 @@ package external
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 TestExternal(t *testing.T) {
RegisterFailHandler(Fail) RegisterFailHandler(Fail)
RunSpecs(t, "External Suite") RunSpecs(t, "External Suite")
} }
var _ = configtest.RestoreAfterEachSpec()

View file

@ -54,7 +54,7 @@ type auxAlbum struct {
// Name returns the appropriate album name for external API calls // Name returns the appropriate album name for external API calls
// based on the DevPreserveUnicodeInExternalCalls configuration option // based on the DevPreserveUnicodeInExternalCalls configuration option
func (a *auxAlbum) Name() string { func (a *auxAlbum) Name() string {
if conf.Server().DevPreserveUnicodeInExternalCalls { if conf.Server.DevPreserveUnicodeInExternalCalls {
return a.Album.Name return a.Album.Name
} }
return str.Clear(a.Album.Name) return str.Clear(a.Album.Name)
@ -67,7 +67,7 @@ type auxArtist struct {
// Name returns the appropriate artist name for external API calls // Name returns the appropriate artist name for external API calls
// based on the DevPreserveUnicodeInExternalCalls configuration option // based on the DevPreserveUnicodeInExternalCalls configuration option
func (a *auxArtist) Name() string { func (a *auxArtist) Name() string {
if conf.Server().DevPreserveUnicodeInExternalCalls { if conf.Server.DevPreserveUnicodeInExternalCalls {
return a.Artist.Name return a.Artist.Name
} }
return str.Clear(a.Artist.Name) return str.Clear(a.Artist.Name)
@ -136,7 +136,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
} }
// If info is expired, trigger a populateAlbumInfo in the background // If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server().DevAlbumInfoTimeToLive { if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName) log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
e.albumQueue.enqueue(&album) e.albumQueue.enqueue(&album)
} }
@ -243,7 +243,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
} }
// If info is expired, trigger a populateArtistInfo in the background // If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server().DevArtistInfoTimeToLive { if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName) log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
e.artistQueue.enqueue(&artist) e.artistQueue.enqueue(&artist)
} }

View file

@ -7,6 +7,7 @@ import (
_ "github.com/navidrome/navidrome/adapters/lastfm" _ "github.com/navidrome/navidrome/adapters/lastfm"
_ "github.com/navidrome/navidrome/adapters/listenbrainz" _ "github.com/navidrome/navidrome/adapters/listenbrainz"
"github.com/navidrome/navidrome/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/external" . "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/matcher"
@ -27,8 +28,9 @@ var _ = Describe("Provider - TopSongs", func() {
) )
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
// Disable fuzzy matching for these tests to avoid unexpected GetAll calls // Disable fuzzy matching for these tests to avoid unexpected GetAll calls
conf.Update(func(c *conf.Options) { c.Matcher.FuzzyThreshold = 100 }) conf.Server.Matcher.FuzzyThreshold = 100
ctx = GinkgoT().Context() ctx = GinkgoT().Context()

View file

@ -36,7 +36,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ag = new(mockAgents) ag = new(mockAgents)
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{}) p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo) mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
conf.Update(func(c *conf.Options) { c.DevAlbumInfoTimeToLive = 1 * time.Hour }) conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
}) })
It("returns error when album is not found", func() { It("returns error when album is not found", func() {
@ -89,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
ExternalUrl: "http://cached.com/album", ExternalUrl: "http://cached.com/album",
Description: "Cached Desc", Description: "Cached Desc",
LargeImageUrl: "http://cached.com/large.jpg", LargeImageUrl: "http://cached.com/large.jpg",
ExternalInfoUpdatedAt: new(now.Add(-conf.Server().DevAlbumInfoTimeToLive / 2)), ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)),
} }
mockAlbumRepo.SetData(model.Albums{*originalAlbum}) mockAlbumRepo.SetData(model.Albums{*originalAlbum})
@ -104,7 +104,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
It("returns cached info and triggers background refresh when info is expired", func() { It("returns cached info and triggers background refresh when info is expired", func() {
now := time.Now() now := time.Now()
expiredTime := now.Add(-conf.Server().DevAlbumInfoTimeToLive * 2) expiredTime := now.Add(-conf.Server.DevAlbumInfoTimeToLive * 2)
originalAlbum := &model.Album{ originalAlbum := &model.Album{
ID: "al-expired", ID: "al-expired",
Name: "Expired Album", Name: "Expired Album",

View file

@ -6,6 +6,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/external" "github.com/navidrome/navidrome/core/external"
"github.com/navidrome/navidrome/core/matcher" "github.com/navidrome/navidrome/core/matcher"
@ -31,7 +32,8 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
) )
BeforeEach(func() { BeforeEach(func() {
conf.Update(func(c *conf.Options) { c.DevArtistInfoTimeToLive = 1 * time.Hour }) DeferCleanup(configtest.SetupConfig())
conf.Server.DevArtistInfoTimeToLive = 1 * time.Hour
ctx = GinkgoT().Context() ctx = GinkgoT().Context()
ds = new(tests.MockDataStore) ds = new(tests.MockDataStore)
ag = new(mockAgents) ag = new(mockAgents)
@ -153,7 +155,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
ExternalUrl: "http://cached.url", ExternalUrl: "http://cached.url",
Biography: "Cached Bio", Biography: "Cached Bio",
LargeImageUrl: "http://cached_large.jpg", LargeImageUrl: "http://cached_large.jpg",
ExternalInfoUpdatedAt: new(now.Add(-conf.Server().DevArtistInfoTimeToLive / 2)), ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{ SimilarArtists: model.Artists{
{ID: "ar-similar-present", Name: "Similar Present"}, {ID: "ar-similar-present", Name: "Similar Present"},
{ID: "ar-similar-absent", Name: "Similar Absent"}, {ID: "ar-similar-absent", Name: "Similar Absent"},
@ -186,7 +188,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
It("returns cached info and triggers background refresh when info is expired", func() { It("returns cached info and triggers background refresh when info is expired", func() {
now := time.Now() now := time.Now()
expiredTime := now.Add(-conf.Server().DevArtistInfoTimeToLive * 2) expiredTime := now.Add(-conf.Server.DevArtistInfoTimeToLive * 2)
originalArtist := &model.Artist{ originalArtist := &model.Artist{
ID: "ar-expired", ID: "ar-expired",
Name: "Expired Artist", Name: "Expired Artist",
@ -221,7 +223,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
originalArtist := &model.Artist{ originalArtist := &model.Artist{
ID: "ar-similar-test", ID: "ar-similar-test",
Name: "Similar Test Artist", Name: "Similar Test Artist",
ExternalInfoUpdatedAt: new(now.Add(-conf.Server().DevArtistInfoTimeToLive / 2)), ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)),
SimilarArtists: model.Artists{ SimilarArtists: model.Artists{
{ID: "ar-sim-present", Name: "Similar Present"}, {ID: "ar-sim-present", Name: "Similar Present"},
{ID: "", Name: "Similar Absent Raw"}, {ID: "", Name: "Similar Absent Raw"},

View file

@ -32,7 +32,7 @@ type TranscodeOptions struct {
Channels int // 0 = no constraint Channels int // 0 = no constraint
BitDepth int // 0 = no constraint; valid values: 16, 24, 32 BitDepth int // 0 = no constraint; valid values: 16, 24, 32
Offset int // seconds Offset int // seconds
Duration float32 // seconds; 0 = unknown. Only used to repair a header ffmpeg piped out unfinished. Duration float32 // seconds; 0 = unknown. Only used to repair a piped FLAC header.
} }
// AudioProbeResult contains authoritative audio stream properties from ffprobe. // AudioProbeResult contains authoritative audio stream properties from ffprobe.
@ -89,7 +89,7 @@ func (e *ffmpeg) Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadC
if err != nil { if err != nil {
return nil, err return nil, err
} }
return patchPipedHeader(out, opts.Duration-float32(opts.Offset)), nil return patchFLACDuration(out, opts.Duration-float32(opts.Offset)), nil
} }
func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) { func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error) {
@ -603,8 +603,8 @@ func ffprobePath(ffmpegCmd string) string {
func ffmpegCmd() (string, error) { func ffmpegCmd() (string, error) {
ffOnce.Do(func() { ffOnce.Do(func() {
if conf.Server().FFmpegPath != "" { if conf.Server.FFmpegPath != "" {
ffmpegPath = conf.Server().FFmpegPath ffmpegPath = conf.Server.FFmpegPath
ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath) ffmpegPath, ffmpegErr = exec.LookPath(ffmpegPath)
} else { } else {
ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg") ffmpegPath, ffmpegErr = exec.LookPath("ffmpeg")

View file

@ -1,9 +1,7 @@
package ffmpeg package ffmpeg
import ( import (
"bytes"
"context" "context"
"encoding/binary"
"errors" "errors"
"io" "io"
"os" "os"
@ -714,23 +712,6 @@ var _ = Describe("ffmpeg", func() {
Expect(err).ToNot(HaveOccurred()) Expect(err).ToNot(HaveOccurred())
Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100))) Expect(readTotalSamples(out)).To(Equal(uint64(2 * 44100)))
}) })
It("inserts an Info frame on a piped mp3 transcode", func() {
stream, err := ff.Transcode(GinkgoT().Context(), TranscodeOptions{
Command: "ffmpeg -i %s -map 0:a:0 -v 0 -b:a 128k -f mp3 -",
Format: "mp3",
FilePath: "tests/fixtures/test.flac",
Duration: 1, // the fixture is exactly 1s at 44100Hz
})
Expect(err).ToNot(HaveOccurred())
defer stream.Close()
out, err := io.ReadAll(stream)
Expect(err).ToNot(HaveOccurred())
at := bytes.Index(out, []byte("Info"))
Expect(at).To(BeNumerically(">", 0))
Expect(binary.BigEndian.Uint32(out[at+8:])).To(Equal(uint32(38)))
})
}) })
Context("stderr capture", func() { Context("stderr capture", func() {

View file

@ -1,7 +1,10 @@
package ffmpeg package ffmpeg
import ( import (
"bytes"
"encoding/binary" "encoding/binary"
"errors"
"io"
"math" "math"
) )
@ -10,21 +13,43 @@ const (
flacMaxTotalSamples = 1<<36 - 1 flacMaxTotalSamples = 1<<36 - 1
) )
// flacPrefix fills in the STREAMINFO total_samples that ffmpeg leaves at 0 when // patchFLACDuration fills in the STREAMINFO total_samples that ffmpeg leaves at 0
// writing to a pipe, since a decoder cannot seek a cached FLAC without it. // when writing to a pipe, since a decoder cannot seek a cached FLAC without it.
func (h *headerPatcher) flacPrefix(buf []byte) ([]byte, error) { func patchFLACDuration(r io.ReadCloser, duration float32) io.ReadCloser {
buf, err := h.fill(buf, flacPrefixLen) if duration <= 0 {
if err != nil || len(buf) < flacPrefixLen { return r
return buf, err
} }
setFLACTotalSamples(buf, h.duration) return &flacPatcher{ReadCloser: r, duration: duration}
return buf, nil }
type flacPatcher struct {
io.ReadCloser
duration float32
// Peeking here rather than in the constructor keeps Transcode from blocking
// until ffmpeg has emitted its first bytes.
stream io.Reader
}
func (f *flacPatcher) Read(p []byte) (int, error) {
if f.stream == nil {
prefix := make([]byte, flacPrefixLen)
n, err := io.ReadFull(f.ReadCloser, prefix)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return 0, err
}
prefix = prefix[:n]
if err == nil {
setFLACTotalSamples(prefix, f.duration)
}
f.stream = io.MultiReader(bytes.NewReader(prefix), f.ReadCloser)
}
return f.stream.Read(p)
} }
// setFLACTotalSamples takes the rate from the header rather than the transcode // setFLACTotalSamples takes the rate from the header rather than the transcode
// options, so a resampled (-ar) output still gets the right count. // options, so a resampled (-ar) output still gets the right count.
func setFLACTotalSamples(prefix []byte, duration float32) { func setFLACTotalSamples(prefix []byte, duration float32) {
if prefix[4]&0x7F != 0 { // the first metadata block must be STREAMINFO if string(prefix[:4]) != "fLaC" || prefix[4]&0x7F != 0 {
return return
} }
// 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples // 20-bit rate | 3-bit channels | 5-bit depth | 36-bit total_samples

View file

@ -2,6 +2,8 @@ package ffmpeg
import ( import (
"bytes" "bytes"
"errors"
"io"
"os" "os"
. "github.com/onsi/ginkgo/v2" . "github.com/onsi/ginkgo/v2"
@ -17,7 +19,7 @@ func readTotalSamples(b []byte) uint64 {
return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25]) return uint64(b[21]&0x0F)<<32 | uint64(b[22])<<24 | uint64(b[23])<<16 | uint64(b[24])<<8 | uint64(b[25])
} }
var _ = Describe("flacPrefix", func() { var _ = Describe("patchFLACDuration", func() {
var fileFLAC []byte var fileFLAC []byte
// Zeroing total_samples reproduces what a piped transcode emits. // Zeroing total_samples reproduces what a piped transcode emits.
@ -28,6 +30,12 @@ var _ = Describe("flacPrefix", func() {
return b return b
} }
readAll := func(in []byte, duration float32) []byte {
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
BeforeEach(func() { BeforeEach(func() {
var err error var err error
fileFLAC, err = os.ReadFile("tests/fixtures/test.flac") fileFLAC, err = os.ReadFile("tests/fixtures/test.flac")
@ -36,7 +44,7 @@ var _ = Describe("flacPrefix", func() {
}) })
It("fills in total_samples from the duration", func() { It("fills in total_samples from the duration", func() {
out := readPatched(pipedFLAC(), 1.0) out := readAll(pipedFLAC(), 1.0)
Expect(readTotalSamples(out)).To(Equal(uint64(44100))) Expect(readTotalSamples(out)).To(Equal(uint64(44100)))
}) })
@ -46,7 +54,7 @@ var _ = Describe("flacPrefix", func() {
in[18], in[19] = 0x0B, 0xB8 in[18], in[19] = 0x0B, 0xB8
in[20] &= 0x0F in[20] &= 0x0F
out := readPatched(in, 2.0) out := readAll(in, 2.0)
Expect(readSampleRate(out)).To(Equal(48000)) Expect(readSampleRate(out)).To(Equal(48000))
Expect(readTotalSamples(out)).To(Equal(uint64(96000))) Expect(readTotalSamples(out)).To(Equal(uint64(96000)))
@ -54,43 +62,81 @@ var _ = Describe("flacPrefix", func() {
It("rounds to the nearest sample rather than truncating", func() { It("rounds to the nearest sample rather than truncating", func() {
// float32(0.7)*44100 is 30869.9995, so truncation would lose a sample. // float32(0.7)*44100 is 30869.9995, so truncation would lose a sample.
out := readPatched(pipedFLAC(), 0.7) out := readAll(pipedFLAC(), 0.7)
Expect(readTotalSamples(out)).To(Equal(uint64(30870))) Expect(readTotalSamples(out)).To(Equal(uint64(30870)))
}) })
It("passes through when the duration overflows the 36-bit field", func() { It("passes through when the duration overflows the 36-bit field", func() {
in := pipedFLAC() in := pipedFLAC()
Expect(readPatched(in, 2e6)).To(Equal(in)) Expect(readAll(in, 2e6)).To(Equal(in))
}) })
It("leaves everything after the header untouched", func() { It("leaves everything after the header untouched", func() {
in := pipedFLAC() in := pipedFLAC()
out := readPatched(in, 1.0) out := readAll(in, 1.0)
Expect(out).To(HaveLen(len(in))) Expect(out).To(HaveLen(len(in)))
Expect(out[26:]).To(Equal(in[26:])) Expect(out[26:]).To(Equal(in[26:]))
Expect(out[:18]).To(Equal(in[:18])) Expect(out[:18]).To(Equal(in[:18]))
}) })
It("leaves an already-populated total_samples alone", func() { It("leaves an already-populated total_samples alone", func() {
out := readPatched(fileFLAC, 99.0) out := readAll(fileFLAC, 99.0)
Expect(out).To(Equal(fileFLAC)) Expect(out).To(Equal(fileFLAC))
}) })
It("passes through a stream that is not FLAC", func() {
in := []byte("ID3\x04\x00\x00\x00\x00\x00\x00 not a flac stream at all, just bytes")
Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through when the first metadata block is not STREAMINFO", func() { It("passes through when the first metadata block is not STREAMINFO", func() {
in := pipedFLAC() in := pipedFLAC()
in[4] = 0x04 // VORBIS_COMMENT in[4] = 0x04 // VORBIS_COMMENT
Expect(readPatched(in, 1.0)).To(Equal(in)) Expect(readAll(in, 1.0)).To(Equal(in))
}) })
It("passes through a stream shorter than the STREAMINFO fields it patches", func() { It("passes through a stream shorter than the STREAMINFO fields it patches", func() {
in := pipedFLAC()[:20] in := pipedFLAC()[:20]
Expect(readPatched(in, 1.0)).To(Equal(in)) Expect(readAll(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readAll(nil, 1.0)).To(BeEmpty())
})
It("passes through when the duration is zero or negative", func() {
in := pipedFLAC()
Expect(readAll(in, 0)).To(Equal(in))
Expect(readAll(in, -5)).To(Equal(in))
}) })
It("passes through when the header declares no sample rate", func() { It("passes through when the header declares no sample rate", func() {
in := pipedFLAC() in := pipedFLAC()
in[18], in[19] = 0, 0 in[18], in[19] = 0, 0
in[20] &= 0x0F in[20] &= 0x0F
Expect(readPatched(in, 1.0)).To(Equal(in)) Expect(readAll(in, 1.0)).To(Equal(in))
})
It("propagates a read error from the underlying stream", func() {
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
Expect(err).To(MatchError("boom"))
})
It("closes the underlying stream", func() {
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
Expect(c.closed).To(BeTrue())
}) })
}) })
type errReader struct{}
func (e *errReader) Read([]byte) (int, error) { return 0, errors.New("boom") }
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }

View file

@ -1,142 +0,0 @@
package ffmpeg
import (
"encoding/binary"
"math"
"slices"
"github.com/navidrome/navidrome/utils/gg"
)
const (
mp3HeaderLen = 4
mp3ID3Len = 10
mp3InfoTagLen = 12 // tag, flags and frame count
mp3MaxPrefix = 64 << 10 // ffmpeg cannot write an attached picture to a pipe, so the tag stays small
)
// mp3Prefix inserts an Info frame before the first audio frame, returning the head
// unchanged when it cannot make sense of what ffmpeg wrote.
func (h *headerPatcher) mp3Prefix(buf []byte) ([]byte, error) {
buf, err := h.fill(buf, mp3ID3Len)
if err != nil || len(buf) < mp3ID3Len {
return buf, err
}
start := 0
if string(buf[:3]) == "ID3" {
if start = id3TagLen(buf); start > mp3MaxPrefix {
return buf, nil
}
}
if buf, err = h.fill(buf, start+mp3HeaderLen); err != nil || len(buf) < start+mp3HeaderLen {
return buf, err
}
frame, ok := parseMP3Header(buf[start:])
if !ok {
return buf, nil
}
if buf, err = h.fill(buf, start+frame.size); err != nil || len(buf) < start+frame.size {
return buf, err
}
if isXingFrame(buf[start:], frame.tagOffset) {
return buf, nil
}
xing, ok := xingFrame(buf[start:], h.duration)
if !ok {
return buf, nil
}
return slices.Insert(buf, start, xing...), nil
}
func id3TagLen(header []byte) int {
size := int(header[6]&0x7F)<<21 | int(header[7]&0x7F)<<14 | int(header[8]&0x7F)<<7 | int(header[9]&0x7F)
if header[5]&0x10 != 0 { // footer present
size += mp3ID3Len
}
return mp3ID3Len + size
}
type mp3Frame struct {
sampleRate int
samples int // per frame
size int // bytes, including the header
tagOffset int // where a Xing tag sits in the incoming frame, which may carry a CRC
}
var (
mp3BitRates = [2][16]int{
{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0}, // MPEG 1
{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160, 0}, // MPEG 2 and 2.5
}
mp3SampleRates = [4][4]int{
{11025, 12000, 8000, 0}, // MPEG 2.5
{}, // reserved
{22050, 24000, 16000, 0}, // MPEG 2
{44100, 48000, 32000, 0}, // MPEG 1
}
)
func parseMP3Header(h []byte) (mp3Frame, bool) {
version, layer := (h[1]>>3)&0x03, (h[1]>>1)&0x03
if h[0] != 0xFF || h[1]&0xE0 != 0xE0 || version == 1 || layer != 1 {
return mp3Frame{}, false // not the sync word of an MPEG Layer III frame
}
mpeg1 := version == 3
sampleRate := mp3SampleRates[version][(h[2]>>2)&0x03]
bitRate := 1000 * mp3BitRates[gg.If(mpeg1, 0, 1)][h[2]>>4]
if sampleRate == 0 || bitRate == 0 {
return mp3Frame{}, false
}
f := mp3Frame{sampleRate: sampleRate, samples: gg.If(mpeg1, 1152, 576)}
var sideInfo int
mono := (h[3]>>6)&0x03 == 3
switch {
case mpeg1 && mono:
sideInfo = 17
case mpeg1:
sideInfo = 32
case mono:
sideInfo = 9
default:
sideInfo = 17
}
f.size = f.samples/8*bitRate/sampleRate + int((h[2]>>1)&0x01)
f.tagOffset = mp3HeaderLen + sideInfo
if h[1]&0x01 == 0 { // CRC follows the header
f.tagOffset += 2
}
return f, true
}
func isXingFrame(frame []byte, tagOffset int) bool {
if len(frame) < tagOffset+4 {
return false
}
tag := string(frame[tagOffset : tagOffset+4])
return tag == "Xing" || tag == "Info"
}
// xingFrame builds a silent Info frame declaring how many frames follow it. Its header is
// the first frame's minus the CRC, with the bitrate raised until the frame fits the tag.
func xingFrame(first []byte, duration float32) ([]byte, bool) {
header := [mp3HeaderLen]byte(first)
header[1] |= 0x01
f, ok := parseMP3Header(header[:])
for ok && f.size < f.tagOffset+mp3InfoTagLen {
header[2] += 0x10 // next bitrate index; 15 is invalid, which ends the loop
f, ok = parseMP3Header(header[:])
}
if !ok {
return nil, false
}
frames := math.Round(float64(duration) * float64(f.sampleRate) / float64(f.samples))
if frames < 1 || frames > math.MaxUint32 {
return nil, false
}
frame := make([]byte, f.size)
copy(frame, header[:])
copy(frame[f.tagOffset:], "Info") // a Xing tag without a seek table marks the stream unseekable to some decoders
binary.BigEndian.PutUint32(frame[f.tagOffset+4:], 1) // only the frame count is present
binary.BigEndian.PutUint32(frame[f.tagOffset+8:], uint32(frames))
return frame, true
}

View file

@ -1,141 +0,0 @@
package ffmpeg
import (
"bytes"
"encoding/binary"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// Offsets of the Xing tag inside the first frame, for the two layouts the specs use.
const (
stereoTagOffset = 36 // 4 header + 32 side info
monoTagOffset = 21 // 4 header + 17 side info
fixtureID3Len = 39581 // where the first frame of tests/fixtures/test.mp3 starts
fixtureFrameLen = 627 // its frame size, at 192kbps and 44100Hz
)
var _ = Describe("mp3Prefix", func() {
var pipedMP3 []byte
// The fixture is an ffmpeg pipe's output in every way that matters here: ID3 tag,
// frames, no Xing.
BeforeEach(func() {
var err error
pipedMP3, err = os.ReadFile("tests/fixtures/test.mp3")
Expect(err).ToNot(HaveOccurred())
Expect(pipedMP3[fixtureID3Len : fixtureID3Len+2]).To(Equal([]byte{0xFF, 0xFB}))
Expect(bytes.Contains(pipedMP3[:4096], []byte("Xing"))).To(BeFalse())
Expect(bytes.Contains(pipedMP3[:4096], []byte("Info"))).To(BeFalse())
})
// Reads the tag and frame count of the frame starting at frameStart.
readXing := func(b []byte, frameStart, tagOffset int) (string, uint32) {
at := frameStart + tagOffset
return string(b[at : at+4]), binary.BigEndian.Uint32(b[at+8:])
}
It("inserts an Info frame declaring the duration in frames", func() {
out := readPatched(pipedMP3, 1.0)
tag, frames := readXing(out, fixtureID3Len, stereoTagOffset)
Expect(tag).To(Equal("Info"))
Expect(frames).To(Equal(uint32(38)), "1s at 44100Hz is 38 frames of 1152 samples")
flags := binary.BigEndian.Uint32(out[fixtureID3Len+stereoTagOffset+4:])
Expect(flags).To(Equal(uint32(1)), "only the frame count is present")
})
It("leaves the ID3 tag and the audio frames untouched", func() {
out := readPatched(pipedMP3, 1.0)
start := fixtureID3Len
Expect(out).To(HaveLen(len(pipedMP3) + fixtureFrameLen))
Expect(out[:start]).To(Equal(pipedMP3[:start]))
Expect(out[start+fixtureFrameLen:]).To(Equal(pipedMP3[start:]))
})
It("takes the sample rate from the frame header", func() {
in := bytes.Clone(pipedMP3)
start := fixtureID3Len
in[start+2] = in[start+2]&^0x0C | 0x04 // sample rate index 1: 48000Hz
out := readPatched(in, 1.0)
_, frames := readXing(out, start, stereoTagOffset)
Expect(frames).To(Equal(uint32(42))) // 48000/1152, rounded
})
It("rounds the frame count to the nearest frame", func() {
out := readPatched(pipedMP3, 0.99) // 37.9 frames
_, frames := readXing(out, fixtureID3Len, stereoTagOffset)
Expect(frames).To(Equal(uint32(38)))
})
It("places the tag after the shorter side info of a mono stream", func() {
in := monoMP3()
out := readPatched(in, 1.0)
tag, frames := readXing(out, 0, monoTagOffset)
Expect(tag).To(Equal("Info"))
Expect(frames).To(Equal(uint32(38)))
})
It("raises the bitrate of the inserted frame when the first frame is too small for the tag", func() {
// MPEG 2, 8kbps, 22050Hz, stereo: 26 bytes, as a VBR encode starting on silence emits.
in := make([]byte, 26)
copy(in, []byte{0xFF, 0xF3, 0x10, 0x00})
out := readPatched(in, 1.0)
Expect(out[2]>>4).To(Equal(byte(2)), "16kbps, the first bitrate whose frame fits the tag")
tag, frames := readXing(out, 0, 21) // 4 header + 17 side info
Expect(tag).To(Equal("Info"))
Expect(frames).To(Equal(uint32(38))) // 1s at 22050Hz is 38 frames of 576 samples
Expect(out[52:]).To(Equal(in)) // 52 bytes = frame size at 16kbps, 22050Hz
})
It("inserts the frame at the start of a stream with no ID3 tag", func() {
in := pipedMP3[fixtureID3Len:]
out := readPatched(in, 1.0)
tag, _ := readXing(out, 0, stereoTagOffset)
Expect(tag).To(Equal("Info"))
Expect(out[fixtureFrameLen:]).To(Equal(in))
})
It("leaves a stream that already declares its duration alone", func() {
patched := readPatched(pipedMP3, 1.0)
Expect(readPatched(patched, 99.0)).To(Equal(patched))
})
It("passes through a frame header with a reserved bitrate or sample rate", func() {
in := monoMP3()
in[2] |= 0xF0 // bitrate index 15 is invalid
Expect(readPatched(in, 1.0)).To(Equal(in))
in = monoMP3()
in[2] |= 0x0C // sample rate index 3 is reserved
Expect(readPatched(in, 1.0)).To(Equal(in))
})
It("passes through a stream too short to hold a frame header", func() {
in := monoMP3()[:3]
Expect(readPatched(in, 1.0)).To(Equal(in))
})
It("passes through a stream whose ID3 tag never ends", func() {
in := bytes.Clone(pipedMP3[:200])
Expect(readPatched(in, 1.0)).To(Equal(in))
})
})
// monoMP3 is one silent MPEG1 Layer III frame, 128kbps, 44100Hz, mono.
func monoMP3() []byte {
b := make([]byte, 417)
copy(b, []byte{0xFF, 0xFB, 0x90, 0xC0})
return b
}

View file

@ -1,61 +0,0 @@
package ffmpeg
import (
"bytes"
"errors"
"io"
)
// patchPipedHeader repairs the header ffmpeg cannot finish when its output is a pipe,
// since it only learns the sample or frame count once it can no longer rewind.
func patchPipedHeader(r io.ReadCloser, duration float32) io.ReadCloser {
if duration <= 0 {
return r
}
return &headerPatcher{ReadCloser: r, duration: duration}
}
type headerPatcher struct {
io.ReadCloser
duration float32
// Peeking here rather than in the constructor keeps Transcode from blocking
// until ffmpeg has emitted its first bytes.
stream io.Reader
}
func (h *headerPatcher) Read(p []byte) (int, error) {
if h.stream == nil {
prefix, err := h.peek()
if err != nil {
return 0, err
}
h.stream = io.MultiReader(bytes.NewReader(prefix), h.ReadCloser)
}
return h.stream.Read(p)
}
// peek dispatches on the magic bytes rather than on the requested format, which is a
// free-text label on the transcoding profile and need not match what ffmpeg emits.
func (h *headerPatcher) peek() ([]byte, error) {
buf, err := h.fill(nil, 4)
if err != nil || len(buf) < 4 {
return buf, err
}
if string(buf) == "fLaC" {
return h.flacPrefix(buf)
}
return h.mp3Prefix(buf)
}
// fill grows buf to n bytes, stopping short when the stream ends first.
func (h *headerPatcher) fill(buf []byte, n int) ([]byte, error) {
if len(buf) >= n {
return buf, nil
}
more := make([]byte, n-len(buf))
read, err := io.ReadFull(h.ReadCloser, more)
if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {
return buf, err
}
return append(buf, more[:read]...), nil
}

View file

@ -1,58 +0,0 @@
package ffmpeg
import (
"bytes"
"errors"
"io"
"testing/iotest"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func readPatched(in []byte, duration float32) []byte {
out, err := io.ReadAll(patchPipedHeader(io.NopCloser(bytes.NewReader(in)), duration))
Expect(err).ToNot(HaveOccurred())
return out
}
var _ = Describe("patchPipedHeader", func() {
It("passes through when the duration is zero or negative", func() {
in := monoMP3()
Expect(readPatched(in, 0)).To(Equal(in))
Expect(readPatched(in, -5)).To(Equal(in))
})
It("passes through a stream that is neither FLAC nor mp3", func() {
in := []byte("OggS\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00 and then some bytes")
Expect(readPatched(in, 1.0)).To(Equal(in))
})
It("passes through an ADTS stream, whose sync word looks like mp3's", func() {
in := append([]byte{0xFF, 0xF1, 0x50, 0x80, 0x4D, 0x1F, 0xFC}, make([]byte, 600)...)
Expect(readPatched(in, 1.0)).To(Equal(in))
})
It("passes through an empty stream", func() {
Expect(readPatched(nil, 1.0)).To(BeEmpty())
})
It("propagates a read error from the underlying stream", func() {
r := patchPipedHeader(io.NopCloser(iotest.ErrReader(errors.New("ffmpeg died"))), 1.0)
_, err := io.ReadAll(r)
Expect(err).To(MatchError(ContainSubstring("ffmpeg died")))
})
It("closes the underlying stream", func() {
c := &closeSpy{Reader: bytes.NewReader(monoMP3())}
Expect(patchPipedHeader(c, 1.0).Close()).To(Succeed())
Expect(c.closed).To(BeTrue())
})
})
type closeSpy struct {
io.Reader
closed bool
}
func (c *closeSpy) Close() error { c.closed = true; return nil }

View file

@ -15,7 +15,7 @@ type InspectOutput struct {
MappedTags *model.MediaFile `json:"mappedTags,omitempty"` MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
} }
func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) { func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
path, file := filepath.Split(filePath) path, file := filepath.Split(filePath)
s, err := storage.For(path) s, err := storage.For(path)
@ -39,22 +39,12 @@ func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutpu
return nil, model.ErrNotFound return nil, model.ErrNotFound
} }
md := metadata.New(scannerPath(lib, filePath), tag) md := metadata.New(path, tag)
result := &InspectOutput{ result := &InspectOutput{
File: filePath, File: filePath,
RawTags: tags[file].Tags, RawTags: tags[file].Tags,
MappedTags: new(md.ToMediaFile(lib, folderId)), MappedTags: new(md.ToMediaFile(libraryId, folderId)),
} }
return result, nil return result, nil
} }
// scannerPath returns the path the scanner uses for the file (relative to its library), so
// folder-based PIDs match the DB. Files outside the library keep their absolute path.
func scannerPath(lib model.Library, filePath string) string {
absPath, err := filepath.Abs(filePath)
if err != nil || lib.Path == "" {
return filePath
}
return model.LibraryRelativePath(lib.Path, absPath)
}

View file

@ -1,45 +0,0 @@
package core_test
import (
"path/filepath"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Inspect", func() {
var fixtures string
BeforeEach(func() {
var err error
fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures"))
Expect(err).ToNot(HaveOccurred())
})
It("maps the file with the library-relative path the scanner uses", func() {
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3"))
Expect(out.MappedTags.LibraryID).To(Equal(2))
})
It("gives the same IDs for relative and absolute paths", func() {
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "")
Expect(err).ToNot(HaveOccurred())
Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID))
Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID))
})
It("keeps the given path for a file outside the library", func() {
filePath := filepath.Join(fixtures, "test.mp3")
out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "")
Expect(err).ToNot(HaveOccurred())
Expect(out.MappedTags.Path).To(Equal(filePath))
})
})

View file

@ -2,11 +2,10 @@ package core
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"os"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -16,7 +15,6 @@ import (
"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/request" "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/events" "github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/utils/slice" "github.com/navidrome/navidrome/utils/slice"
@ -202,23 +200,24 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity
} }
pathChanged := originalLib.Path != lib.Path pathChanged := originalLib.Path != lib.Path
pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) ||
(updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack)
err = r.LibraryRepository.Put(ctx, lib, cols...) err = r.LibraryRepository.Put(ctx, lib, cols...)
if err != nil { if err != nil {
return r.mapError(err) return r.mapError(err)
} }
if pathChanged && r.watcher != nil { // Restart watcher and trigger scan if path was updated
if pathChanged {
if r.watcher != nil {
if err := r.watcher.Watch(ctx, lib); err != nil { if err := r.watcher.Watch(ctx, lib); err != nil {
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err) log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
} }
} }
if (pathChanged || pidChanged) && r.scanner != nil { if r.scanner != nil {
go r.triggerScan(ctx, lib, "updated") go r.triggerScan(ctx, lib, "updated")
} }
}
// Send library refresh event to all clients // Send library refresh event to all clients
if r.broker != nil { if r.broker != nil {
@ -326,15 +325,6 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
} }
} }
library.PIDAlbum = strings.TrimSpace(library.PIDAlbum)
library.PIDTrack = strings.TrimSpace(library.PIDTrack)
if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil {
validationErrors["pidAlbum"] = err.Error()
}
if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil {
validationErrors["pidTrack"] = err.Error()
}
if len(validationErrors) > 0 { if len(validationErrors) > 0 {
return &rest.ValidationError{Errors: validationErrors} return &rest.ValidationError{Errors: validationErrors}
} }
@ -342,11 +332,6 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
return nil return nil
} }
// updatesColumn reports whether an update with these columns writes col. No columns means all of them.
func updatesColumn(cols []string, col string) bool {
return len(cols) == 0 || slices.Contains(cols, col)
}
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error { func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
// Validate path format // Validate path format
if !filepath.IsAbs(library.Path) { if !filepath.IsAbs(library.Path) {
@ -366,14 +351,25 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, libr
fsys, err := fileStore.FS() fsys, err := fileStore.FS()
if err != nil { if err != nil {
log.Warn(ctx, "Error validating library.path", "path", library.Path, err) log.Warn(ctx, "Error validating library.path", "path", library.Path, err)
return errors.New(classifyLibraryPathError(err)) return fmt.Errorf("resources.library.validation.pathInvalid")
} }
// Check if root directory exists // Check if root directory exists
info, err := fs.Stat(fsys, ".") info, err := fs.Stat(fsys, ".")
if err != nil { if err != nil {
// Parse the error message to check for "not a directory"
log.Warn(ctx, "Error stating library.path", "path", library.Path, err) log.Warn(ctx, "Error stating library.path", "path", library.Path, err)
return errors.New(classifyLibraryPathError(err)) errStr := err.Error()
if strings.Contains(errStr, "not a directory") ||
strings.Contains(errStr, "The directory name is invalid.") {
return fmt.Errorf("resources.library.validation.pathNotDirectory")
} else if os.IsNotExist(err) {
return fmt.Errorf("resources.library.validation.pathNotFound")
} else if os.IsPermission(err) {
return fmt.Errorf("resources.library.validation.pathNotAccessible")
} else {
return fmt.Errorf("resources.library.validation.pathInvalid")
}
} }
if !info.IsDir() { if !info.IsDir() {
@ -383,22 +379,6 @@ func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, libr
return nil return nil
} }
// classifyLibraryPathError maps a path error to its i18n key. It uses errors.Is because storage FS() wraps with %w.
// Not-a-directory is matched by text: syscall.ENOTDIR means ERROR_PATH_NOT_FOUND on Windows, and ERROR_DIRECTORY is not exported.
func classifyLibraryPathError(err error) string {
switch {
case errors.Is(err, fs.ErrNotExist):
return "resources.library.validation.pathNotFound"
case errors.Is(err, fs.ErrPermission):
return "resources.library.validation.pathNotAccessible"
case strings.Contains(err.Error(), "not a directory") ||
strings.Contains(err.Error(), "The directory name is invalid."):
return "resources.library.validation.pathNotDirectory"
default:
return "resources.library.validation.pathInvalid"
}
}
func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error { func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []int) error {
if len(libraryIDs) == 0 { if len(libraryIDs) == 0 {
return nil return nil
@ -419,27 +399,11 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
return nil return nil
} }
var scanWaitInterval = time.Second
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) { func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
// Runs in its own goroutine and outlives the HTTP request
ctx = context.WithoutCancel(ctx)
// A running scan loaded the libraries before this change, and would reject a new request
for {
status, err := r.scanner.Status(ctx)
if err != nil || !status.Scanning {
break
}
time.Sleep(scanWaitInterval)
}
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path) log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
start := time.Now() start := time.Now()
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library
if errors.Is(err, model.ErrAlreadyScanning) { if err != nil {
log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name)
} else if err != nil {
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err) log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
} else { } else {
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start)) log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))

View file

@ -1,29 +0,0 @@
package core
import (
"errors"
"fmt"
"io/fs"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("classifyLibraryPathError", func() {
DescribeTable("maps a filesystem error to a validation message key",
func(err error, expected string) {
Expect(classifyLibraryPathError(err)).To(Equal(expected))
},
Entry("missing path", fs.ErrNotExist, "resources.library.validation.pathNotFound"),
Entry("wrapped missing path", fmt.Errorf("stat /music: %w", fs.ErrNotExist), "resources.library.validation.pathNotFound"),
Entry("permission denied", fs.ErrPermission, "resources.library.validation.pathNotAccessible"),
Entry("wrapped permission denied", fmt.Errorf("open /music: %w", fs.ErrPermission), "resources.library.validation.pathNotAccessible"),
Entry("file instead of a directory on Unix",
&fs.PathError{Op: "open", Path: "/music/song.mp3", Err: errors.New("not a directory")},
"resources.library.validation.pathNotDirectory"),
Entry("file instead of a directory on Windows",
&fs.PathError{Op: "open", Path: `C:\music\song.mp3`, Err: errors.New("The directory name is invalid.")},
"resources.library.validation.pathNotDirectory"),
Entry("any other error", errors.New("input/output error"), "resources.library.validation.pathInvalid"),
)
})

View file

@ -10,6 +10,7 @@ import (
"github.com/deluan/rest" "github.com/deluan/rest"
_ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor _ "github.com/navidrome/navidrome/adapters/gotaglib" // Register taglib extractor
"github.com/navidrome/navidrome/conf/configtest"
"github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core"
_ "github.com/navidrome/navidrome/core/storage/local" // Register local storage _ "github.com/navidrome/navidrome/core/storage/local" // Register local storage
"github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model"
@ -34,6 +35,7 @@ var _ = Describe("Library Service", func() {
var pluginManager *mockPluginUnloader var pluginManager *mockPluginUnloader
BeforeEach(func() { BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{} ds = &tests.MockDataStore{}
libraryRepo = &tests.MockLibraryRepo{} libraryRepo = &tests.MockLibraryRepo{}
@ -320,37 +322,6 @@ var _ = Describe("Library Service", func() {
}) })
}) })
Describe("PID validation", func() {
pidError := func(err error, field string) string {
var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue())
return validationErr.Errors[field]
}
It("rejects an unknown attribute in the album PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"})
Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`))
})
It("rejects albumid in the album PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"})
Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid"))
})
It("rejects an unknown attribute in the track PID", func() {
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"})
Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`))
})
It("trims spaces", func() {
library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "}
_, err := repo.Save(ctx, library)
Expect(err).ToNot(HaveOccurred())
Expect(library.PIDAlbum).To(Equal("folder"))
Expect(library.PIDTrack).To(BeEmpty())
})
})
Describe("Path Validation", func() { Describe("Path Validation", func() {
Context("Create operation", func() { Context("Create operation", func() {
It("fails when path is not absolute", func() { It("fails when path is not absolute", func() {
@ -373,7 +344,7 @@ var _ = Describe("Library Service", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue()) Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotFound")) Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
}) })
It("fails when path is a file instead of directory", func() { It("fails when path is a file instead of directory", func() {
@ -453,7 +424,7 @@ var _ = Describe("Library Service", func() {
Expect(err).To(HaveOccurred()) Expect(err).To(HaveOccurred())
var validationErr *rest.ValidationError var validationErr *rest.ValidationError
Expect(errors.As(err, &validationErr)).To(BeTrue()) Expect(errors.As(err, &validationErr)).To(BeTrue())
Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathNotFound")) Expect(validationErr.Errors["path"]).To(Equal("resources.library.validation.pathInvalid"))
}) })
It("fails when updated path is a file instead of directory", func() { It("fails when updated path is a file instead of directory", func() {
@ -708,48 +679,6 @@ var _ = Describe("Library Service", func() {
}, "100ms", "10ms").Should(Equal(0)) }, "100ms", "10ms").Should(Equal(0))
}) })
It("triggers scan when updating the library PID config", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
Expect(repo.Update(ctx, "1", library)).To(Succeed())
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// A quick scan: the scanner itself rescans this library in full
Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse())
})
It("does not trigger scan when the PID fields were not sent", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}})
// The REST layer decodes a missing pidAlbum as "". Only the sent fields count.
library := model.Library{ID: 1, Name: "Renamed", Path: tempDir}
Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
It("waits for a running scan before triggering a new one", func() {
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
scanner.SetScanning(true)
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
Expect(repo.Update(ctx, "1", library)).To(Succeed())
Consistently(func() int {
return scanner.GetScanAllCallCount()
}, "200ms", "20ms").Should(Equal(0))
scanner.SetScanning(false)
Eventually(func() int {
return scanner.GetScanAllCallCount()
}, "3s", "20ms").Should(Equal(1))
})
It("does not trigger scan when library creation fails", func() { It("does not trigger scan when library creation fails", func() {
// Try to create library with invalid data (empty name) // Try to create library with invalid data (empty name)
library := &model.Library{Path: tempDir} library := &model.Library{Path: tempDir}

Some files were not shown because too many files have changed in this diff Show more