mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-14 22:28:41 +01:00
Compare commits
6 commits
master
...
feat/relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec6f3e1914 | ||
|
|
a607858873 | ||
|
|
d315c2fe0b | ||
|
|
82eea674a5 | ||
|
|
e3fde1fd74 | ||
|
|
9be5c6f94b |
436 changed files with 5955 additions and 8820 deletions
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
102
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,74 +1,98 @@
|
||||||
name: Bug report
|
name: Bug Report
|
||||||
description: Report something that is not working as expected. You do not need to know the cause or how to fix it.
|
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["triage"]
|
labels: ["bug", "triage"]
|
||||||
#assignees:
|
#assignees:
|
||||||
# - deluan
|
# - deluan
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
Thanks for reporting a problem. For a possible security vulnerability, use [private reporting](https://github.com/navidrome/navidrome/security/advisories/new).
|
### Thanks for taking the time to fill out this bug report!
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: requirements
|
id: requirements
|
||||||
attributes:
|
attributes:
|
||||||
label: "I confirm that:"
|
label: "I confirm that:"
|
||||||
options:
|
options:
|
||||||
- label: I have searched the existing [open and closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) and found no matching open report.
|
- label: I have searched the existing [open AND closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) to see if an issue already exists for the bug I've encountered
|
||||||
required: true
|
|
||||||
- label: I'm using the latest Navidrome release and can reproduce the issue on it.
|
|
||||||
required: true
|
|
||||||
- type: textarea
|
|
||||||
id: what-happened
|
|
||||||
attributes:
|
|
||||||
label: What happened
|
|
||||||
description: Describe what you observed and how it affected you. Include any error message you saw. You do not need to diagnose the cause or suggest code changes. If a closed issue matches, link it here.
|
|
||||||
validations:
|
|
||||||
required: true
|
required: true
|
||||||
|
- label: I'm using the latest version (your issue may have been fixed already)
|
||||||
|
required: false
|
||||||
- type: input
|
- type: input
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Navidrome version
|
label: Version
|
||||||
description: Which Navidrome version or build are you using? If you cannot find it, enter 'not sure'. If you tried another version, mention whether the behavior changed.
|
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: expected
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected behavior and why
|
label: Current Behavior
|
||||||
description: What did you expect, and why? If available, link documentation, an applicable API contract, or a previously working version. A personal expectation is useful context too; it does not by itself establish that the product should change. If you are unsure, say so.
|
description: A concise description of what you're experiencing.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: steps
|
|
||||||
attributes:
|
attributes:
|
||||||
label: Steps to reproduce
|
label: Expected Behavior
|
||||||
description: List the steps, relevant settings, and how often the problem occurs. If it happens only sometimes, describe what was happening when you noticed it and what you have already tried.
|
description: A concise description of what you expected to happen.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Steps To Reproduce
|
||||||
|
description: Steps to reproduce the behavior.
|
||||||
|
placeholder: |
|
||||||
|
1. In this scenario...
|
||||||
|
2. With this config...
|
||||||
|
3. Click (or Execute) '...'
|
||||||
|
4. See error...
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: env
|
id: env
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant environment
|
label: Environment
|
||||||
description: >-
|
description: |
|
||||||
Include only details relevant to this problem: server operating system, installation method, and the browser or client app with its version, if involved. Leave out details you do not know. Examples of installation methods: Docker, downloaded binary, package, or source build.
|
examples:
|
||||||
validations:
|
- **OS**: Ubuntu 20.04
|
||||||
required: false
|
- **Browser**: Chrome 110.0.5481.177 on Windows 11
|
||||||
- type: textarea
|
- **Client**: DSub 5.5.1
|
||||||
id: config-logs
|
value: |
|
||||||
|
- OS:
|
||||||
|
- Browser:
|
||||||
|
- Client:
|
||||||
|
render: markdown
|
||||||
|
- type: dropdown
|
||||||
|
id: distribution
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant configuration and logs
|
label: How Navidrome is installed?
|
||||||
description: >-
|
multiple: false
|
||||||
Paste only the configuration settings or log excerpt related to this problem. Remove passwords, tokens, API keys, session cookies, private URLs, and identifying information before submitting. Debug logs are optional. If you cannot safely share an excerpt, explain what it shows instead. [Log guidance](https://www.navidrome.org/docs/faq/#-where-are-the-logs).
|
options:
|
||||||
|
- Docker
|
||||||
|
- Binary (from downloads page)
|
||||||
|
- Package
|
||||||
|
- Built from sources
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: additional-context
|
id: config
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots or additional context
|
label: Configuration
|
||||||
description: Add screenshots, recordings, links, or other observations that help explain the problem. Remove private information from attachments as well.
|
description: Please copy and paste your `navidrome.toml` (and/or `docker-compose.yml`) configuration. This will be automatically formatted into code, so no need for backticks.
|
||||||
validations:
|
render: toml
|
||||||
required: false
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Relevant log output
|
||||||
|
description: Please copy and paste any relevant log output (change your `LogLevel` (`ND_LOGLEVEL`) to debug). This will be automatically formatted into code, so no need for backticks. ([Where I can find the logs?](https://www.navidrome.org/docs/faq/#where-are-the-logs))
|
||||||
|
render: shell
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Anything else?
|
||||||
|
description: |
|
||||||
|
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||||
|
|
||||||
|
Tip: You can attach screenshots by clicking this area to highlight it and then dragging files in.
|
||||||
- type: checkboxes
|
- type: checkboxes
|
||||||
id: terms
|
id: terms
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
||||||
6
.github/ISSUE_TEMPLATE/config.yml
vendored
6
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,8 +1,8 @@
|
||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Ideas and behavior changes
|
- name: Ideas for new features
|
||||||
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
||||||
about: Describe the problem, intended outcome, and use case before starting an implementation.
|
about: This is the place to share and discuss new ideas and potentially new features.
|
||||||
- name: Support requests
|
- name: Support requests
|
||||||
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
url: https://github.com/navidrome/navidrome/discussions/categories/q-a
|
||||||
about: Ask for help with setup, configuration, or using Navidrome.
|
about: This is the place to ask questions.
|
||||||
|
|
|
||||||
21
.github/pull_request_template.md
vendored
21
.github/pull_request_template.md
vendored
|
|
@ -1,13 +1,8 @@
|
||||||
### Description
|
### Description
|
||||||
<!-- Please provide a clear and concise description of what this PR does and why it is needed. AI tools may help you draft, edit, or translate it, but review the final text and make sure you agree with it. Please be mindful of the maintainer's time. -->
|
<!-- Please provide a clear and concise description of what this PR does and why it is needed. -->
|
||||||
|
|
||||||
**AI use** (check one):
|
|
||||||
- [ ] Yes, AI helped generate or modify code or tests, draft substantive PR text, or create this PR
|
|
||||||
- [ ] No
|
|
||||||
<!-- Tool names and prompts are not required. -->
|
|
||||||
|
|
||||||
### Related Issues
|
### Related Issues
|
||||||
<!-- List related issues, such as "Fixes #123" or "Related to #456". When prior agreement is required, link the maintainer's agreement on the intended behavior and scope. Otherwise, explain why agreement is not needed, such as the small-change exception or a translation update. For a larger change, link the agreed design and identify this phase. -->
|
<!-- List any related issues, e.g., "Fixes #123" or "Related to #456". -->
|
||||||
|
|
||||||
### Type of Change
|
### Type of Change
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
|
|
@ -17,29 +12,27 @@
|
||||||
- [ ] Other (please describe):
|
- [ ] Other (please describe):
|
||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
Please review and check all that apply. Leave unverified items unchecked and explain them under How to Test.
|
Please review and check all that apply:
|
||||||
|
|
||||||
- [ ] My code follows the project’s coding style
|
- [ ] My code follows the project’s coding style
|
||||||
- [ ] I have tested the changes locally
|
- [ ] I have tested the changes locally
|
||||||
- [ ] I have added or updated documentation as needed
|
- [ ] I have added or updated documentation as needed
|
||||||
- [ ] I have added tests that prove my fix/feature works (or explain why not)
|
- [ ] I have added tests that prove my fix/feature works (or explain why not)
|
||||||
- [ ] All existing and new tests pass
|
- [ ] All existing and new tests pass
|
||||||
- [ ] I personally read and reviewed the entire diff and relevant code before opening this PR, and have done the same for every subsequent update, if any.
|
|
||||||
- [ ] For code changes, I have the development knowledge to understand, test, and maintain the implementation, assess its security and performance implications, and answer review questions.
|
|
||||||
|
|
||||||
### How to Test
|
### How to Test
|
||||||
<!-- Describe the steps to test your changes. Include setup, commands actually run, and results actually observed before and after the change. State how you personally checked the affected functionality. List failed or unrun checks, untested cases, and limitations. -->
|
<!-- Describe the steps to test your changes. Include setup, commands, and expected results. -->
|
||||||
|
|
||||||
### Screenshots / Demos (if applicable)
|
### Screenshots / Demos (if applicable)
|
||||||
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
|
<!-- Add screenshots, GIFs, or links to demos if your change includes UI updates or visual changes. -->
|
||||||
|
|
||||||
### Additional Notes
|
### Additional Notes
|
||||||
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? Where relevant, explain important tradeoffs, new dependencies or ongoing maintenance costs, and simpler alternatives considered. -->
|
<!-- Anything else the maintainer should know? Potential side effects, breaking changes, or areas of concern? -->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
**Tips for Contributors:**
|
**Tips for Contributors:**
|
||||||
- Be concise but thorough.
|
- Be concise but thorough.
|
||||||
- If your PR is large, consider breaking it into smaller PRs within the agreed design and scope.
|
- If your PR is large, consider breaking it into smaller PRs.
|
||||||
- Review timing depends on maintainer availability; please do not tag the maintainer for faster review.
|
- Tag the maintainer if you need a prompt review.
|
||||||
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
|
- Avoid force pushing to the branch after opening the PR, as it can complicate the review process.
|
||||||
-->
|
-->
|
||||||
13
.github/workflows/pipeline.yml
vendored
13
.github/workflows/pipeline.yml
vendored
|
|
@ -79,7 +79,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
version: ${{ steps.golangci-version.outputs.version }}
|
version: ${{ steps.golangci-version.outputs.version }}
|
||||||
problem-matchers: true
|
problem-matchers: true
|
||||||
args: --timeout 3m
|
args: --timeout 2m
|
||||||
|
|
||||||
- name: Run go goimports
|
- name: Run go goimports
|
||||||
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
|
run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'`
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
29
.github/workflows/release-podcast-tests.yml
vendored
Normal file
29
.github/workflows/release-podcast-tests.yml
vendored
Normal 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
126
.github/workflows/release-podcast.yml
vendored
Normal 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
|
||||||
111
CONTRIBUTING.md
111
CONTRIBUTING.md
|
|
@ -1,66 +1,93 @@
|
||||||
# Navidrome Contribution Guide
|
# Navidrome Contribution Guide
|
||||||
|
|
||||||
Navidrome has one maintainer and limited review capacity. We welcome contributions that fit the project and remain practical to maintain. A smaller number of focused, well-understood contributions is more useful than a large volume of code that needs extensive rework.
|
Navidrome is a streaming service which allows you to enjoy your music collection from anywhere. We'd welcome you to contribute to our open source project and make Navidrome even better. There are some basic guidelines which you need to follow if you like to contribute to Navidrome.
|
||||||
|
|
||||||
## Support and conduct
|
- [Asking Support Questions](#asking-support-questions)
|
||||||
|
- [Code of Conduct](#code-of-conduct)
|
||||||
|
- [Issues](#issues)
|
||||||
|
- [Pull Requests](#pull-requests)
|
||||||
|
|
||||||
Use [Discussions → Q&A](https://github.com/navidrome/navidrome/discussions/categories/q-a) for support. Please follow the [Code of Conduct](CODE_OF_CONDUCT.md).
|
|
||||||
|
|
||||||
## Issues and ideas
|
## Asking Support Questions
|
||||||
|
We have an active [discussion forum](https://github.com/navidrome/navidrome/discussions) where users and developers can ask questions. Please don't use the GitHub issue tracker to ask questions.
|
||||||
|
|
||||||
Anyone may report a suspected bug. Search existing [open and closed issues](https://github.com/navidrome/navidrome/issues?q=is%3Aissue) and [discussions](https://github.com/navidrome/navidrome/discussions), and check the [documentation](https://www.navidrome.org/docs/) and [FAQ](https://www.navidrome.org/docs/faq/) first. Use the [bug report form](https://github.com/navidrome/navidrome/issues/new?template=bug_report.yml) for suspected defects and [Discussions → Ideas](https://github.com/navidrome/navidrome/discussions/categories/ideas) for new features or changes to intended behavior.
|
## Code of Conduct
|
||||||
|
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
||||||
|
|
||||||
Describe what happened, what you expected, and why that expectation should hold. Include reproduction steps where possible, with a reference to documentation, an applicable API contract, or a previously working version. If the problem is intermittent, explain what you observed and tried. Remove credentials and private information from logs and configuration.
|
## Issues
|
||||||
|
Found any issue or bug in our codebase? Have a great idea you want to propose or discuss with
|
||||||
|
the developers? You can help by submitting an [issue](https://github.com/navidrome/navidrome/issues/new/choose)
|
||||||
|
to the GitHub repository.
|
||||||
|
|
||||||
A reported problem may involve intended behavior, configuration, or a product decision. An open issue or a label does not establish that a change has been accepted. Report security vulnerabilities [privately through GitHub](https://github.com/navidrome/navidrome/security/advisories/new).
|
**Before opening a new issue, please check if the issue has not been already made by searching
|
||||||
|
the [issues](https://github.com/navidrome/navidrome/issues)**
|
||||||
|
|
||||||
## Agree on the change before implementation
|
## Pull requests
|
||||||
|
Before submitting a pull request, ensure that you go through the following:
|
||||||
|
- Open a corresponding issue for the Pull Request, if not existing. The issue can be opened following [these guidelines](#issues)
|
||||||
|
- Ensure that there is no open or closed Pull Request corresponding to your submission to avoid duplication of effort.
|
||||||
|
- Setup the [development environment](https://www.navidrome.org/docs/developers/dev-environment/)
|
||||||
|
- Create a new branch on your forked repo and make the changes in it. Naming conventions for branch are: `<Issue Title>/<Issue Number>`. Example:
|
||||||
|
```
|
||||||
|
git checkout -b adding-docs/834 master
|
||||||
|
```
|
||||||
|
- The commits should follow a [specific convention](#commit-conventions)
|
||||||
|
- Ensure that a DCO sign-off for commits is provided via `--signoff` option of git commit
|
||||||
|
- Provide a link to the issue that will be closed via your Pull request.
|
||||||
|
|
||||||
For code contributions beyond the small exceptions below, explain the problem, intended behavior, and smallest proposed scope in an existing issue or discussion. Wait for an explicit maintainer comment agreeing that the change should be pursued. Keep the discussion in one place. Silence, community support, and previous merged contributions do not replace that agreement.
|
### Commit Conventions
|
||||||
|
Each commit message must adhere to the following format:
|
||||||
|
```
|
||||||
|
<type>(scope): <description>
|
||||||
|
|
||||||
Small documentation corrections and small, unambiguous fixes that restore established behavior may be submitted directly. Provide evidence of the defect. These exceptions do not cover disputed behavior, new product choices, dependencies, or broader refactoring. If the intended behavior is unclear, discuss it first.
|
[optional body]
|
||||||
|
```
|
||||||
|
This improves the readability of the messages
|
||||||
|
|
||||||
For large or cross-cutting changes, agree on the design and a sequence of small, reviewable phases before implementation. Explain why the change belongs in the core, what new complexity or dependencies it introduces, and which simpler alternatives you considered. Consider existing configuration, APIs, plugins, or external tools where appropriate. Splitting a change into PRs does not remove the need to agree on the overall design and scope.
|
#### Type
|
||||||
|
It can be one of the following:
|
||||||
|
1. **feat**: Addition of a new feature
|
||||||
|
2. **fix**: Bug fix
|
||||||
|
3. **sec**: Fixing security issues
|
||||||
|
4. **docs**: Documentation Changes
|
||||||
|
5. **style**: Changes to styling
|
||||||
|
6. **refactor**: Refactoring of code
|
||||||
|
7. **perf**: Code that affects performance
|
||||||
|
8. **test**: Updating or improving the current tests
|
||||||
|
9. **build**: Changes to Build process
|
||||||
|
10. **revert**: Reverting to a previous commit
|
||||||
|
11. **chore** : updating grunt tasks etc
|
||||||
|
|
||||||
## Review your work before opening a PR
|
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
||||||
|
|
||||||
Before opening any pull request, including a draft, you must personally read and review the entire diff and the relevant surrounding code, understand the implementation, and verify the affected behavior. This applies when an AI agent writes the code or creates the PR for you. An agent's summary or review does not satisfy this requirement. Personally review each subsequent update and verify its effects before pushing it or asking an agent to push it.
|
#### Scope
|
||||||
|
The file or folder where the changes are made. If there are more than one, you can mention any
|
||||||
|
|
||||||
Check the assumptions behind the change. Explain the implementation and important tradeoffs in your own words. For code changes, personally test the affected functionality, add regression tests where practical, and report the commands run and results actually observed. Identify failures, untested cases, and limitations. If an automated test is impractical, explain why and provide manual verification steps.
|
#### Description
|
||||||
|
A short description of the issue
|
||||||
|
|
||||||
Stay involved during review: answer questions, investigate feedback, and revise the work. If you need more time or cannot continue, say so. The maintainer is not committing to finish an abandoned implementation.
|
#### Issue number
|
||||||
|
The issue fixed by this Pull Request.
|
||||||
|
|
||||||
## AI Disclosure
|
The body is optional. It may contain short description of changes made.
|
||||||
|
|
||||||
AI tools may assist professional and hobbyist contributors. All code contributors must be able to personally understand, review, test, and maintain their changes, including security and performance implications; no diploma, job title, or employment is required. If you cannot assess the code yourself, report the problem instead of submitting AI-generated code. Bug reports, documentation improvements, and translations remain welcome.
|
Following all the guidelines an ideal commit will look like:
|
||||||
|
```
|
||||||
|
git commit --signoff -m "feat(themes): New-theme - #834"
|
||||||
|
```
|
||||||
|
|
||||||
AI-assisted work must meet the personal full-diff review and functional testing requirements above before any PR is opened, including drafts or agent-created PRs. You remain responsible for explaining the implementation, addressing feedback, and correcting the code.
|
After committing, push your commits to your forked branch and create a Pull Request from there.
|
||||||
|
The Pull Request Title can be the same as `<type>(scope): <description> - <issue number>`
|
||||||
|
A demo layout of how the Pull request body can look:
|
||||||
|
```
|
||||||
|
Closes <Issue number along with link>
|
||||||
|
|
||||||
Declare whether AI was used to generate or modify submitted code or tests, draft substantive PR text, or create the PR. A simple Yes or No in the PR description is enough. Tool names, prompts, and transcripts are not required.
|
Description (What does the pull request do)
|
||||||
|
|
||||||
AI tools may help you draft, edit, or translate PR explanations and review replies. Review the final text before you send it, make sure you agree with it, and take responsibility for it. Do not send AI-generated text that you have not reviewed to maintainers or other contributors. Please be mindful of the maintainer's time.
|
Changes (What changes were made )
|
||||||
|
|
||||||
## Keep the result maintainable
|
Screenshots or Videos
|
||||||
|
|
||||||
Prefer existing patterns and the smallest clear solution. Justify new abstractions, dependencies, configuration, and compatibility obligations. Include the documentation and tests needed to understand the behavior and change it safely later.
|
Related Issues and Pull Requests(if any)
|
||||||
|
|
||||||
The maintainer must be able to understand and maintain the result without depending on your future availability. An offer to provide ongoing support does not replace this requirement. A working implementation may still be declined if its benefit does not justify its complexity or maintenance cost.
|
```
|
||||||
|
|
||||||
<a id="pull-requests"></a>
|
|
||||||
|
|
||||||
## Submitting and review
|
|
||||||
|
|
||||||
- Check existing [open and closed PRs](https://github.com/navidrome/navidrome/pulls?q=is%3Apr) to avoid duplicate work. If the issue author or another contributor has offered to submit a fix, ask in the issue before you start.
|
|
||||||
- Use the [development environment instructions](https://www.navidrome.org/docs/developers/dev-environment/). Work on a branch in your fork.
|
|
||||||
- Complete the [PR template](.github/pull_request_template.md) with the scope agreement or why agreement is not needed, verification evidence, and maintenance implications. Keep each PR focused.
|
|
||||||
- Follow the [commit conventions and DCO requirements](#commit-conventions-and-dco) below.
|
|
||||||
|
|
||||||
Review and merge are discretionary. Proposals may be declined, simplified, split, or deferred. PRs that do not fit Navidrome's direction, the agreed scope, or the maintainer's capacity to maintain them may be closed without detailed code review or prolonged discussion. PRs lacking evidence or left with unresolved questions may also be closed. Agreement to pursue work does not guarantee review or merge. Please do not tag the maintainer, send private messages, or post status requests to obtain faster review.
|
|
||||||
|
|
||||||
## Commit conventions and DCO
|
|
||||||
|
|
||||||
Use this commit format: `<type>(scope): <description>`, with an optional body.
|
|
||||||
|
|
||||||
Allowed types: `feat`, `fix`, `sec`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `revert`, `chore`.
|
|
||||||
|
|
||||||
Use a relevant file or folder as the scope and a concise description. Reference the related issue when one exists, and include `BREAKING CHANGE` in the body for breaking changes. Sign off every commit using `git commit --signoff`. These requirements apply equally to AI-assisted work.
|
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -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
|
||||||
|
|
|
||||||
25
README.md
25
README.md
|
|
@ -5,7 +5,7 @@
|
||||||
[](https://github.com/navidrome/navidrome/releases)
|
[](https://github.com/navidrome/navidrome/releases)
|
||||||
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
[](https://nightly.link/navidrome/navidrome/workflows/pipeline/master)
|
||||||
[](https://github.com/navidrome/navidrome/releases/latest)
|
[](https://github.com/navidrome/navidrome/releases/latest)
|
||||||
[](https://hub.docker.com/r/navidrome/navidrome)
|
[](https://hub.docker.com/r/deluan/navidrome)
|
||||||
[](https://discord.gg/xh7j7yF)
|
[](https://discord.gg/xh7j7yF)
|
||||||
[](https://www.reddit.com/r/navidrome/)
|
[](https://www.reddit.com/r/navidrome/)
|
||||||
[](CODE_OF_CONDUCT.md)
|
[](CODE_OF_CONDUCT.md)
|
||||||
|
|
@ -21,11 +21,9 @@ the `master` branch in order to get a stable set of binaries.
|
||||||
|
|
||||||
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
## [Check out our Live Demo!](https://www.navidrome.org/demo/)
|
||||||
|
|
||||||
__Any feedback is welcome!__ If you find a bug, please file a [GitHub issue](https://github.com/navidrome/navidrome/issues/new/choose).
|
__Any feedback is welcome!__ If you need/want a new feature, find a bug or think of any way to improve Navidrome,
|
||||||
If you need/want a new feature or think of any way to improve Navidrome, please share it in
|
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
||||||
[Discussions](https://github.com/navidrome/navidrome/discussions/categories/ideas) or join the discussion in our
|
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
||||||
[Subreddit](https://www.reddit.com/r/navidrome/). Before contributing code, please read the
|
|
||||||
[Contribution Guide](CONTRIBUTING.md). If you want to contribute to the project in any other way
|
|
||||||
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
([ui/backend dev](https://www.navidrome.org/docs/developers/),
|
||||||
[translations](https://www.navidrome.org/docs/developers/translations/),
|
[translations](https://www.navidrome.org/docs/developers/translations/),
|
||||||
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
[themes](https://www.navidrome.org/docs/developers/creating-themes)), please join the chat in our
|
||||||
|
|
@ -37,18 +35,11 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
||||||
|
|
||||||
## Cloud Hosting
|
## Cloud Hosting
|
||||||
|
|
||||||
Several cloud hosting providers partner with us to offer [officially supported, cloud-hosted solutions](https://www.navidrome.org/docs/installation/managed). If you sign up with any of these providers, a share of the revenue funds the development of Navidrome at no additional cost for you.
|
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
||||||
|
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
||||||
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
||||||
<br>
|
|
||||||
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
|
||||||
<br>
|
|
||||||
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[](https://www.pikapods.com/pods?run=navidrome)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ type deezerAgent struct {
|
||||||
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
func deezerConstructor(dataStore model.DataStore) agents.Interface {
|
||||||
agent := &deezerAgent{
|
agent := &deezerAgent{
|
||||||
dataStore: dataStore,
|
dataStore: dataStore,
|
||||||
languages: conf.Server().Deezer.Languages,
|
languages: conf.Server.Deezer.Languages,
|
||||||
}
|
}
|
||||||
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
httpClient := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
|
||||||
|
|
@ -192,7 +192,7 @@ func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server().Deezer.Enabled {
|
if conf.Server.Deezer.Enabled {
|
||||||
agents.Register(deezerAgentName, deezerConstructor)
|
agents.Register(deezerAgentName, deezerConstructor)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package deezer
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestDeezer(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Deezer Test Suite")
|
RunSpecs(t, "Deezer Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -22,12 +23,13 @@ var _ = Describe("deezerAgent", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
conf.Update(func(c *conf.Options) { c.Deezer.Enabled = true })
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.Deezer.Enabled = true
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("deezerConstructor", func() {
|
Describe("deezerConstructor", func() {
|
||||||
It("uses configured languages", func() {
|
It("uses configured languages", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Deezer.Languages = []string{"pt", "en"} })
|
conf.Server.Deezer.Languages = []string{"pt", "en"}
|
||||||
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
|
agent := deezerConstructor(&tests.MockDataStore{}).(*deezerAgent)
|
||||||
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
Expect(agent.languages).To(Equal([]string{"pt", "en"}))
|
||||||
})
|
})
|
||||||
|
|
@ -177,7 +179,7 @@ var _ = Describe("deezerAgent", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
setupAgent := func(languages []string) {
|
setupAgent := func(languages []string) {
|
||||||
conf.Update(func(c *conf.Options) { c.Deezer.Languages = languages })
|
conf.Server.Deezer.Languages = languages
|
||||||
agent = &deezerAgent{
|
agent = &deezerAgent{
|
||||||
dataStore: &tests.MockDataStore{},
|
dataStore: &tests.MockDataStore{},
|
||||||
client: newClient(httpClient),
|
client: newClient(httpClient),
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ var _ = Describe("Extractor", func() {
|
||||||
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
info.FileInfo = testFileInfo{FileInfo: fileInfo}
|
||||||
|
|
||||||
metadata := metadata.New(path, info)
|
metadata := metadata.New(path, info)
|
||||||
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
return new(metadata.ToMediaFile(1, "folderID"))
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,14 @@ type lastfmAgent struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
func lastFMConstructor(ds model.DataStore) *lastfmAgent {
|
||||||
if !conf.Server().LastFM.Enabled || conf.Server().LastFM.ApiKey == "" || conf.Server().LastFM.Secret == "" {
|
if !conf.Server.LastFM.Enabled || conf.Server.LastFM.ApiKey == "" || conf.Server.LastFM.Secret == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
l := &lastfmAgent{
|
l := &lastfmAgent{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
languages: conf.Server().LastFM.Languages,
|
languages: conf.Server.LastFM.Languages,
|
||||||
apiKey: conf.Server().LastFM.ApiKey,
|
apiKey: conf.Server.LastFM.ApiKey,
|
||||||
secret: conf.Server().LastFM.Secret,
|
secret: conf.Server.LastFM.Secret,
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
}
|
}
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package lastfm
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestLastFM(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "LastFM Test Suite")
|
RunSpecs(t, "LastFM Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ func listenBrainzConstructor(ds model.DataStore) *listenBrainzAgent {
|
||||||
l := &listenBrainzAgent{
|
l := &listenBrainzAgent{
|
||||||
ds: ds,
|
ds: ds,
|
||||||
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty},
|
||||||
baseURL: conf.Server().ListenBrainz.BaseURL,
|
baseURL: conf.Server.ListenBrainz.BaseURL,
|
||||||
}
|
}
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
|
||||||
|
|
@ -229,7 +229,7 @@ func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSe
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
conf.AddHook(func() {
|
conf.AddHook(func() {
|
||||||
if conf.Server().ListenBrainz.Enabled {
|
if conf.Server.ListenBrainz.Enabled {
|
||||||
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
scrobbler.Register(listenBrainzAgentName, func(ds model.DataStore) scrobbler.Scrobbler {
|
||||||
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
// This is a workaround for the fact that a (Interface)(nil) is not the same as a (*listenBrainzAgent)(nil)
|
||||||
// See https://go.dev/doc/faq#nil_error
|
// See https://go.dev/doc/faq#nil_error
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ func NewRouter(ds model.DataStore) *Router {
|
||||||
}
|
}
|
||||||
r.Handler = r.routes()
|
r.Handler = r.routes()
|
||||||
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
hc := httpclient.New(consts.DefaultHttpClientTimeOut)
|
||||||
r.client = newClient(conf.Server().ListenBrainz.BaseURL, hc)
|
r.client = newClient(conf.Server.ListenBrainz.BaseURL, hc)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ func (c *client) getSimilarArtists(ctx context.Context, mbid string, limit int)
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-artists/json", nil)
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
||||||
req.URL.RawQuery = url.Values{
|
req.URL.RawQuery = url.Values{
|
||||||
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server().ListenBrainz.ArtistAlgorithm},
|
"artist_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.ArtistAlgorithm},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
||||||
|
|
@ -354,7 +354,7 @@ func (c *client) getSimilarRecordings(ctx context.Context, mbid string, limit in
|
||||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, labsBase+"similar-recordings/json", nil)
|
||||||
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
req.Header.Add("Content-Type", "application/json; charset=UTF-8")
|
||||||
req.URL.RawQuery = url.Values{
|
req.URL.RawQuery = url.Values{
|
||||||
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server().ListenBrainz.TrackAlgorithm},
|
"recording_mbids": []string{mbid}, "algorithm": []string{conf.Server.ListenBrainz.TrackAlgorithm},
|
||||||
}.Encode()
|
}.Encode()
|
||||||
|
|
||||||
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
log.Trace(ctx, fmt.Sprintf("Sending ListenBrainz Labs %s request", req.Method), "url", req.URL)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -256,6 +257,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
})
|
})
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
getUrl := func(mbid string) string {
|
||||||
|
|
@ -305,7 +307,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
It("fetches a different endpoint when algorithm changes", func() {
|
It("fetches a different endpoint when algorithm changes", func() {
|
||||||
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
algorithm = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
||||||
conf.Update(func(c *conf.Options) { c.ListenBrainz.ArtistAlgorithm = algorithm })
|
conf.Server.ListenBrainz.ArtistAlgorithm = algorithm
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-artists.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
@ -326,6 +328,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
algorithm = "session_based_days_9000_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
})
|
})
|
||||||
|
|
||||||
getUrl := func(mbid string) string {
|
getUrl := func(mbid string) string {
|
||||||
|
|
@ -441,7 +444,7 @@ var _ = Describe("client", func() {
|
||||||
|
|
||||||
It("uses a different algorithm when configured", func() {
|
It("uses a different algorithm when configured", func() {
|
||||||
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
algorithm = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||||
conf.Update(func(c *conf.Options) { c.ListenBrainz.TrackAlgorithm = algorithm })
|
conf.Server.ListenBrainz.TrackAlgorithm = algorithm
|
||||||
|
|
||||||
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
f, _ := os.Open("tests/fixtures/listenbrainz.labs.similar-recordings.json")
|
||||||
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
httpClient.Res = http.Response{Body: f, StatusCode: 200}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package listenbrainz
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestListenBrainz(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "ListenBrainz Test Suite")
|
RunSpecs(t, "ListenBrainz Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestCmd(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Cmd Suite")
|
RunSpecs(t, "Cmd Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
"github.com/navidrome/navidrome/db"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
|
||||||
"github.com/pelletier/go-toml/v2"
|
"github.com/pelletier/go-toml/v2"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
@ -32,7 +28,7 @@ var inspectCmd = &cobra.Command{
|
||||||
Long: "Show file tags as seen by Navidrome",
|
Long: "Show file tags as seen by Navidrome",
|
||||||
Args: cobra.MinimumNArgs(1),
|
Args: cobra.MinimumNArgs(1),
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runInspector(cmd.Context(), args)
|
runInspector(args)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,24 +55,18 @@ func prettyMarshal(v any) ([]byte, error) {
|
||||||
return []byte(res.String()), nil
|
return []byte(res.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspector(ctx context.Context, args []string) {
|
func runInspector(args []string) {
|
||||||
marshal := marshalers[format]
|
marshal := marshalers[format]
|
||||||
if marshal == nil {
|
if marshal == nil {
|
||||||
log.Fatal("Invalid format", "format", format)
|
log.Fatal("Invalid format", "format", format)
|
||||||
}
|
}
|
||||||
libs := loadLibraries(ctx)
|
|
||||||
matcher := model.NewLibraryMatcher(libs)
|
|
||||||
var out []core.InspectOutput
|
var out []core.InspectOutput
|
||||||
for _, filePath := range args {
|
for _, filePath := range args {
|
||||||
if !model.IsAudioFile(filePath) {
|
if !model.IsAudioFile(filePath) {
|
||||||
log.Warn("Not an audio file", "file", filePath)
|
log.Warn("Not an audio file", "file", filePath)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lib, ok := libraryForFile(matcher, filePath)
|
output, err := core.Inspect(filePath, 1, "")
|
||||||
if !ok && len(libs) > 0 {
|
|
||||||
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
|
||||||
}
|
|
||||||
output, err := core.Inspect(filePath, lib, "")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn("Unable to process file", "file", filePath, "error", err)
|
log.Warn("Unable to process file", "file", filePath, "error", err)
|
||||||
continue
|
continue
|
||||||
|
|
@ -87,33 +77,3 @@ func runInspector(ctx context.Context, args []string) {
|
||||||
data, _ := marshal(out)
|
data, _ := marshal(out)
|
||||||
fmt.Println(string(data))
|
fmt.Println(string(data))
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadLibraries reads the libraries, so each file gets its library's PID config. It never creates a DB.
|
|
||||||
func loadLibraries(ctx context.Context) model.Libraries {
|
|
||||||
if dbFile, ok := existingDBFile(); !ok {
|
|
||||||
log.Warn(ctx, "No database found, using the global PID config", "path", dbFile)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer db.Init(ctx)()
|
|
||||||
libs, err := persistence.New(db.Db()).Library().GetAll(ctx)
|
|
||||||
if err != nil {
|
|
||||||
log.Warn(ctx, "Could not load libraries, using the global PID config", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i := range libs {
|
|
||||||
if absPath, err := filepath.Abs(libs[i].Path); err == nil {
|
|
||||||
libs[i].Path = absPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return libs
|
|
||||||
}
|
|
||||||
|
|
||||||
// libraryForFile falls back to the default library with no overrides, which uses the global PID config.
|
|
||||||
func libraryForFile(matcher *model.LibraryMatcher, filePath string) (model.Library, bool) {
|
|
||||||
if absPath, err := filepath.Abs(filePath); err == nil {
|
|
||||||
if lib, ok := matcher.FindLibrary(absPath); ok {
|
|
||||||
return lib, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.Library{ID: model.DefaultLibraryID}, false
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
package cmd
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("inspect", func() {
|
|
||||||
Describe("libraryForFile", func() {
|
|
||||||
var matcher *model.LibraryMatcher
|
|
||||||
var root string
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
root = GinkgoT().TempDir()
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
matcher = model.NewLibraryMatcher(model.Libraries{
|
|
||||||
{ID: 1, Path: filepath.Join(root, "music")},
|
|
||||||
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns the library that contains an absolute path", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(lib.ID).To(Equal(1))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("resolves a relative path against the working directory", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
|
|
||||||
Expect(ok).To(BeTrue())
|
|
||||||
Expect(lib.PIDAlbum).To(Equal("folder"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("falls back to the default library without overrides", func() {
|
|
||||||
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
|
|
||||||
Expect(ok).To(BeFalse())
|
|
||||||
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("loadLibraries", func() {
|
|
||||||
|
|
||||||
It("does not create a database when there is none", func() {
|
|
||||||
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
|
|
||||||
conf.Update(func(c *conf.Options) { c.DbPath = dbFile + "?_journal_mode=WAL" })
|
|
||||||
|
|
||||||
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
|
|
||||||
Expect(dbFile).ToNot(BeAnExistingFile())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
72
cmd/root.go
72
cmd/root.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
|
||||||
26
cmd/svc.go
26
cmd/svc.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
13
cmd/utils.go
13
cmd/utils.go
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
package configtest
|
package configtest
|
||||||
|
|
||||||
import (
|
import "github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/onsi/ginkgo/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RestoreAfterEachSpec restores the config after every spec. Call it at the top level of a suite.
|
// TODO Remove this redirection and call SnapshotConfig directly from tests
|
||||||
// Ordered specs are skipped so a BeforeAll can set config for its whole container.
|
func SetupConfig() func() {
|
||||||
func RestoreAfterEachSpec() bool {
|
return conf.SnapshotConfig()
|
||||||
return ginkgo.BeforeEach(func() {
|
|
||||||
if !ginkgo.CurrentSpecReport().IsInOrderedContainer {
|
|
||||||
ginkgo.DeferCleanup(conf.SnapshotConfig())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package configtest_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConfigtest(t *testing.T) {
|
|
||||||
RegisterFailHandler(Fail)
|
|
||||||
RunSpecs(t, "Configtest Suite")
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
package configtest_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("RestoreAfterEachSpec", func() {
|
|
||||||
// Either spec can run first: the second one sees the first one's change if it leaks.
|
|
||||||
It("starts with the original value (A)", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(BeEmpty())
|
|
||||||
conf.Update(func(c *conf.Options) { c.UIWelcomeMessage = "A" })
|
|
||||||
})
|
|
||||||
|
|
||||||
It("starts with the original value (B)", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(BeEmpty())
|
|
||||||
conf.Update(func(c *conf.Options) { c.UIWelcomeMessage = "B" })
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("in an Ordered container", Ordered, func() {
|
|
||||||
BeforeAll(func() {
|
|
||||||
DeferCleanup(conf.SnapshotConfig())
|
|
||||||
conf.Update(func(c *conf.Options) { c.UIWelcomeMessage = "ordered" })
|
|
||||||
})
|
|
||||||
|
|
||||||
It("sees the BeforeAll change in the first spec", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(Equal("ordered"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("still sees the BeforeAll change in later specs", func() {
|
|
||||||
Expect(conf.Server().UIWelcomeMessage).To(Equal("ordered"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -3,6 +3,7 @@ package conf
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"encoding"
|
"encoding"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -29,7 +30,7 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct {
|
type configOptions struct {
|
||||||
ConfigFile string `conf:"-"`
|
ConfigFile string `conf:"-"`
|
||||||
Address string
|
Address string
|
||||||
Port int
|
Port int
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package conf
|
package conf
|
||||||
|
|
||||||
func ResetConf() {
|
func ResetConf() {
|
||||||
current.Store(&Options{})
|
Server = &configOptions{}
|
||||||
}
|
}
|
||||||
|
|
||||||
var SetViperDefaults = setViperDefaults
|
var SetViperDefaults = setViperDefaults
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
package conf
|
|
||||||
|
|
||||||
import (
|
|
||||||
"reflect"
|
|
||||||
"sync/atomic"
|
|
||||||
)
|
|
||||||
|
|
||||||
var current atomic.Pointer[Options]
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
current.Store(&Options{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server returns a copy of the current configuration. A published Options is never changed in place,
|
|
||||||
// so readers need no locking. Use Update to change it.
|
|
||||||
func Server() Options {
|
|
||||||
return *current.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies fn to a deep copy of the current configuration and publishes the result.
|
|
||||||
// fn may run more than once if another change lands first, so it must only set fields.
|
|
||||||
func Update(fn func(*Options)) {
|
|
||||||
for {
|
|
||||||
old := current.Load()
|
|
||||||
next := clone(old)
|
|
||||||
fn(next)
|
|
||||||
if current.CompareAndSwap(old, next) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SnapshotConfig returns a function that restores the configuration to its current state.
|
|
||||||
func SnapshotConfig() func() {
|
|
||||||
snapshot := current.Load()
|
|
||||||
return func() { current.Store(snapshot) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func clone(o *Options) *Options {
|
|
||||||
var c Options
|
|
||||||
deepCopy(reflect.ValueOf(&c).Elem(), reflect.ValueOf(o).Elem())
|
|
||||||
return &c
|
|
||||||
}
|
|
||||||
|
|
||||||
func deepCopy(dst, src reflect.Value) {
|
|
||||||
switch src.Kind() {
|
|
||||||
case reflect.Struct:
|
|
||||||
dst.Set(src)
|
|
||||||
for i := range src.NumField() {
|
|
||||||
if dst.Field(i).CanSet() {
|
|
||||||
deepCopy(dst.Field(i), src.Field(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case reflect.Map:
|
|
||||||
if src.IsNil() {
|
|
||||||
dst.Set(src)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m := reflect.MakeMapWithSize(src.Type(), src.Len())
|
|
||||||
for k, v := range src.Seq2() {
|
|
||||||
nv := reflect.New(v.Type()).Elem()
|
|
||||||
deepCopy(nv, v)
|
|
||||||
m.SetMapIndex(k, nv)
|
|
||||||
}
|
|
||||||
dst.Set(m)
|
|
||||||
case reflect.Slice:
|
|
||||||
if src.IsNil() {
|
|
||||||
dst.Set(src)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s := reflect.MakeSlice(src.Type(), src.Len(), src.Len())
|
|
||||||
for i := range src.Len() {
|
|
||||||
deepCopy(s.Index(i), src.Index(i))
|
|
||||||
}
|
|
||||||
dst.Set(s)
|
|
||||||
case reflect.Pointer:
|
|
||||||
if src.IsNil() {
|
|
||||||
dst.Set(src)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p := reflect.New(src.Type().Elem())
|
|
||||||
deepCopy(p.Elem(), src.Elem())
|
|
||||||
dst.Set(p)
|
|
||||||
default:
|
|
||||||
dst.Set(src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
package conf_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
|
||||||
. "github.com/onsi/gomega"
|
|
||||||
)
|
|
||||||
|
|
||||||
var _ = Describe("Server state", func() {
|
|
||||||
BeforeEach(func() {
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.Tags = map[string]conf.TagConf{"genre": {Aliases: []string{"genre", "style"}}}
|
|
||||||
c.DevLogLevels = map[string]string{"scanner": "info"}
|
|
||||||
c.Scanner.ArtistSplitExceptions = []string{"AC/DC"}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps a copy taken from Server unchanged by later updates", func() {
|
|
||||||
before := conf.Server()
|
|
||||||
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.Tags["genre"].Aliases[0] = "changed"
|
|
||||||
c.DevLogLevels["scanner"] = "debug"
|
|
||||||
c.Scanner.ArtistSplitExceptions[0] = "changed"
|
|
||||||
})
|
|
||||||
|
|
||||||
Expect(before.Tags["genre"].Aliases).To(Equal([]string{"genre", "style"}))
|
|
||||||
Expect(before.DevLogLevels).To(Equal(map[string]string{"scanner": "info"}))
|
|
||||||
Expect(before.Scanner.ArtistSplitExceptions).To(Equal([]string{"AC/DC"}))
|
|
||||||
Expect(conf.Server().DevLogLevels["scanner"]).To(Equal("debug"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("restores map and slice contents changed in place by Update", func() {
|
|
||||||
restore := conf.SnapshotConfig()
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.Tags["genre"].Aliases[0] = "changed"
|
|
||||||
c.DevLogLevels["scanner"] = "debug"
|
|
||||||
})
|
|
||||||
|
|
||||||
restore()
|
|
||||||
|
|
||||||
Expect(conf.Server().Tags["genre"].Aliases).To(Equal([]string{"genre", "style"}))
|
|
||||||
Expect(conf.Server().DevLogLevels).To(Equal(map[string]string{"scanner": "info"}))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("lets readers run while the config is updated and restored", func() {
|
|
||||||
stop := make(chan struct{})
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Go(func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-stop:
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
_ = conf.Server().DevLogLevels["scanner"]
|
|
||||||
_ = conf.Server().Scanner.Enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
for range 100 {
|
|
||||||
restore := conf.SnapshotConfig()
|
|
||||||
conf.Update(func(c *conf.Options) {
|
|
||||||
c.DevLogLevels["scanner"] = "debug"
|
|
||||||
c.Scanner.Enabled = !c.Scanner.Enabled
|
|
||||||
})
|
|
||||||
restore()
|
|
||||||
}
|
|
||||||
close(stop)
|
|
||||||
wg.Wait()
|
|
||||||
})
|
|
||||||
|
|
||||||
It("keeps every change when updates run at the same time", func() {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Port = 0 })
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for range 50 {
|
|
||||||
wg.Go(func() {
|
|
||||||
for range 20 {
|
|
||||||
conf.Update(func(c *conf.Options) { c.Port++ })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
Expect(conf.Server().Port).To(Equal(1000))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ type enabledAgent struct {
|
||||||
// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false)
|
// Each enabledAgent contains the name and whether it's a plugin (true) or built-in (false)
|
||||||
func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||||
// If no agents configured, ONLY use the local agent
|
// If no agents configured, ONLY use the local agent
|
||||||
if conf.Server().Agents == "" {
|
if conf.Server.Agents == "" {
|
||||||
return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
|
return []enabledAgent{{name: LocalAgentName, isPlugin: false}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
|
||||||
}
|
}
|
||||||
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
log.Trace("Available MetadataAgent plugins", "plugins", availablePlugins)
|
||||||
|
|
||||||
configuredAgents := strings.Split(conf.Server().Agents, ",")
|
configuredAgents := strings.Split(conf.Server.Agents, ",")
|
||||||
|
|
||||||
// Always add LocalAgentName if not already included
|
// Always add LocalAgentName if not already included
|
||||||
hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
|
hasLocalAgent := slices.Contains(configuredAgents, LocalAgentName)
|
||||||
|
|
@ -181,7 +181,7 @@ type AlbumImageAgent struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever,
|
// ArtistImageAgents returns the enabled agents implementing ArtistImageRetriever,
|
||||||
// in conf.Server().Agents order (same order the aggregate dispatch uses).
|
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||||
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
||||||
var result []ArtistImageAgent
|
var result []ArtistImageAgent
|
||||||
for _, ea := range a.getEnabledAgentNames() {
|
for _, ea := range a.getEnabledAgentNames() {
|
||||||
|
|
@ -193,7 +193,7 @@ func (a *Agents) ArtistImageAgents() []ArtistImageAgent {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever,
|
// AlbumImageAgents returns the enabled agents implementing AlbumImageRetriever,
|
||||||
// in conf.Server().Agents order (same order the aggregate dispatch uses).
|
// in conf.Server.Agents order (same order the aggregate dispatch uses).
|
||||||
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
|
func (a *Agents) AlbumImageAgents() []AlbumImageAgent {
|
||||||
var result []AlbumImageAgent
|
var result []AlbumImageAgent
|
||||||
for _, ea := range a.getEnabledAgentNames() {
|
for _, ea := range a.getEnabledAgentNames() {
|
||||||
|
|
@ -256,7 +256,7 @@ func (a *Agents) GetArtistBiography(ctx context.Context, id, name, mbid string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
// GetSimilarArtists returns similar artists by id, name, and/or mbid. Because some artists returned from an enabled
|
||||||
// agent may not exist in the database, return at most limit * conf.Server().DevExternalArtistFetchMultiplier items.
|
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
||||||
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, limit int) ([]Artist, error) {
|
||||||
switch id {
|
switch id {
|
||||||
case consts.UnknownArtistID:
|
case consts.UnknownArtistID:
|
||||||
|
|
@ -265,7 +265,7 @@ func (a *Agents) GetSimilarArtists(ctx context.Context, id, name, mbid string, l
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
overLimit := int(float64(limit) * conf.Server().DevExternalArtistFetchMultiplier)
|
overLimit := int(float64(limit) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
attempts := newAttempts(&a.cooldowns)
|
attempts := newAttempts(&a.cooldowns)
|
||||||
|
|
@ -316,7 +316,7 @@ func (a *Agents) GetArtistImages(ctx context.Context, id, name, mbid string) ([]
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
// GetArtistTopSongs returns top songs by id, name, and/or mbid. Because some songs returned from an enabled
|
||||||
// agent may not exist in the database, return at most limit * conf.Server().DevExternalArtistFetchMultiplier items.
|
// agent may not exist in the database, return at most limit * conf.Server.DevExternalArtistFetchMultiplier items.
|
||||||
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||||
switch id {
|
switch id {
|
||||||
case consts.UnknownArtistID:
|
case consts.UnknownArtistID:
|
||||||
|
|
@ -325,7 +325,7 @@ func (a *Agents) GetArtistTopSongs(ctx context.Context, id, artistName, mbid str
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
overLimit := int(float64(count) * conf.Server().DevExternalArtistFetchMultiplier)
|
overLimit := int(float64(count) * conf.Server.DevExternalArtistFetchMultiplier)
|
||||||
|
|
||||||
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
return callAgentSliceMethod(ctx, a, "GetArtistTopSongs", func(ag Interface) ([]Song, error) {
|
||||||
retriever, ok := ag.(ArtistTopSongsRetriever)
|
retriever, ok := ag.(ArtistTopSongsRetriever)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
Context("Dynamic agent discovery", func() {
|
Context("Dynamic agent discovery", func() {
|
||||||
It("should include ONLY local agent when no config is specified", func() {
|
It("should include ONLY local agent when no config is specified", func() {
|
||||||
// Ensure no specific agents are configured
|
// Ensure no specific agents are configured
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
|
|
||||||
// Add some plugin agents that should be ignored
|
// Add some plugin agents that should be ignored
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent", "another_plugin")
|
||||||
|
|
@ -82,7 +82,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
|
|
||||||
It("should NOT include plugin agents when no config is specified", func() {
|
It("should NOT include plugin agents when no config is specified", func() {
|
||||||
// Ensure no specific agents are configured
|
// Ensure no specific agents are configured
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
|
|
||||||
// Add a plugin agent
|
// Add a plugin agent
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
@ -99,13 +99,13 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
||||||
// With no config, should not include plugin
|
// With no config, should not include plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
enabledAgents := agents.getEnabledAgentNames()
|
enabledAgents := agents.getEnabledAgentNames()
|
||||||
Expect(enabledAgents).To(HaveLen(1))
|
Expect(enabledAgents).To(HaveLen(1))
|
||||||
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
Expect(enabledAgents[0].name).To(Equal(LocalAgentName))
|
||||||
|
|
||||||
// When explicitly configured, should include plugin
|
// When explicitly configured, should include plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_agent" })
|
conf.Server.Agents = "plugin_agent"
|
||||||
enabledAgents = agents.getEnabledAgentNames()
|
enabledAgents = agents.getEnabledAgentNames()
|
||||||
var agentNames []string
|
var agentNames []string
|
||||||
var pluginAgentFound bool
|
var pluginAgentFound bool
|
||||||
|
|
@ -125,7 +125,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_one", "plugin_two")
|
||||||
|
|
||||||
// Configure only one of them
|
// Configure only one of them
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_one" })
|
conf.Server.Agents = "plugin_one"
|
||||||
|
|
||||||
// Verify only the configured one is included
|
// Verify only the configured one is included
|
||||||
enabledAgents := agents.getEnabledAgentNames()
|
enabledAgents := agents.getEnabledAgentNames()
|
||||||
|
|
@ -147,7 +147,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Configure to use our plugin
|
// Configure to use our plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_agent" })
|
conf.Server.Agents = "plugin_agent"
|
||||||
|
|
||||||
// Add a plugin agent
|
// Add a plugin agent
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
@ -177,7 +177,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Configure to use both built-in and plugin
|
// Configure to use both built-in and plugin
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "built_in,plugin_agent" })
|
conf.Server.Agents = "built_in,plugin_agent"
|
||||||
|
|
||||||
// Add a plugin agent
|
// Add a plugin agent
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_agent")
|
||||||
|
|
@ -223,7 +223,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y")
|
mockLoader.pluginNames = append(mockLoader.pluginNames, "plugin_x", "plugin_y")
|
||||||
|
|
||||||
// Configure specific order - plugin first, then built-ins
|
// Configure specific order - plugin first, then built-ins
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "plugin_y,agent_b,plugin_x,agent_a" })
|
conf.Server.Agents = "plugin_y,agent_b,plugin_x,agent_a"
|
||||||
|
|
||||||
// Get the agent names
|
// Get the agent names
|
||||||
enabledAgents := agents.getEnabledAgentNames()
|
enabledAgents := agents.getEnabledAgentNames()
|
||||||
|
|
@ -250,7 +250,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Configure to use only built-in agents
|
// Configure to use only built-in agents
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "builtin_agent" })
|
conf.Server.Agents = "builtin_agent"
|
||||||
|
|
||||||
// Call GetArtistMBID which should only use the built-in agent
|
// Call GetArtistMBID which should only use the built-in agent
|
||||||
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
mbid, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||||
|
|
@ -266,7 +266,7 @@ var _ = Describe("Agents with Plugin Loading", func() {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Configure with an invalid agent name (not built-in, not a plugin)
|
// Configure with an invalid agent name (not built-in, not a plugin)
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "invalid_agent" })
|
conf.Server.Agents = "invalid_agent"
|
||||||
|
|
||||||
// This should only result in using the local agent (as the invalid one is ignored)
|
// This should only result in using the local agent (as the invalid one is ignored)
|
||||||
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
_, err := agents.GetArtistMBID(ctx, "123", "Artist")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package agents
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -16,5 +15,3 @@ func TestAgents(t *testing.T) {
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "Agents Test Suite")
|
RunSpecs(t, "Agents Test Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
|
|
@ -44,6 +45,7 @@ var _ = Describe("Agents", func() {
|
||||||
var ds model.DataStore
|
var ds model.DataStore
|
||||||
var mfRepo *tests.MockMediaFileRepo
|
var mfRepo *tests.MockMediaFileRepo
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx, cancel = context.WithCancel(context.Background())
|
ctx, cancel = context.WithCancel(context.Background())
|
||||||
mfRepo = tests.CreateMockMediaFileRepo()
|
mfRepo = tests.CreateMockMediaFileRepo()
|
||||||
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
ds = &tests.MockDataStore{MockedMediaFile: mfRepo}
|
||||||
|
|
@ -52,7 +54,7 @@ var _ = Describe("Agents", func() {
|
||||||
Describe("Local", func() {
|
Describe("Local", func() {
|
||||||
var ag *Agents
|
var ag *Agents
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" })
|
conf.Server.Agents = ""
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -72,7 +74,7 @@ var _ = Describe("Agents", func() {
|
||||||
Register("fake", func(model.DataStore) Interface { return mock })
|
Register("fake", func(model.DataStore) Interface { return mock })
|
||||||
Register("disabled", func(model.DataStore) Interface { return nil })
|
Register("disabled", func(model.DataStore) Interface { return nil })
|
||||||
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
|
Register("empty", func(model.DataStore) Interface { return &emptyAgent{} })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "empty,fake,disabled" })
|
conf.Server.Agents = "empty,fake,disabled"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
Expect(ag.AgentName()).To(Equal("agents"))
|
Expect(ag.AgentName()).To(Equal("agents"))
|
||||||
})
|
})
|
||||||
|
|
@ -250,7 +252,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
|
It("returns ErrNotFound when another agent answered with a definitive miss", func() {
|
||||||
other := &mockAgent{Err: ErrNotFound}
|
other := &mockAgent{Err: ErrNotFound}
|
||||||
Register("fake2", func(model.DataStore) Interface { return other })
|
Register("fake2", func(model.DataStore) Interface { return other })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "fake,fake2" })
|
conf.Server.Agents = "fake,fake2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||||
|
|
||||||
|
|
@ -267,7 +269,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("returns ErrNotFound when another agent answered with an empty slice", func() {
|
It("returns ErrNotFound when another agent answered with an empty slice", func() {
|
||||||
empty := &testImageAgent{Name: "emptyImages"}
|
empty := &testImageAgent{Name: "emptyImages"}
|
||||||
Register("emptyImages", func(model.DataStore) Interface { return empty })
|
Register("emptyImages", func(model.DataStore) Interface { return empty })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "fake,emptyImages" })
|
conf.Server.Agents = "fake,emptyImages"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||||
|
|
||||||
|
|
@ -285,7 +287,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
|
It("returns ErrNotFound from GetSimilarArtists when another agent answered", func() {
|
||||||
other := &mockAgent{Err: ErrNotFound}
|
other := &mockAgent{Err: ErrNotFound}
|
||||||
Register("fake2", func(model.DataStore) Interface { return other })
|
Register("fake2", func(model.DataStore) Interface { return other })
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "fake,fake2" })
|
conf.Server.Agents = "fake,fake2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
mock.Err = &RetryLaterError{RetryIn: time.Hour}
|
||||||
|
|
||||||
|
|
@ -328,7 +330,7 @@ var _ = Describe("Agents", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to the next agent on error", func() {
|
It("falls back to the next agent on error", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "imgFail,imgOk" })
|
conf.Server.Agents = "imgFail,imgOk"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||||
|
|
@ -341,7 +343,7 @@ var _ = Describe("Agents", func() {
|
||||||
It("falls back if the first agent returns no images", func() {
|
It("falls back if the first agent returns no images", func() {
|
||||||
first.Err = nil
|
first.Err = nil
|
||||||
first.Images = []ExternalImage{}
|
first.Images = []ExternalImage{}
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "imgFail,imgOk" })
|
conf.Server.Agents = "imgFail,imgOk"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
images, err := ag.GetArtistImages(ctx, "id", "artist", "mbid")
|
||||||
|
|
@ -377,7 +379,7 @@ var _ = Describe("Agents", func() {
|
||||||
|
|
||||||
Describe("GetArtistTopSongs", func() {
|
Describe("GetArtistTopSongs", func() {
|
||||||
It("returns on first match", func() {
|
It("returns on first match", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevExternalArtistFetchMultiplier = 1 })
|
conf.Server.DevExternalArtistFetchMultiplier = 1
|
||||||
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
||||||
Name: "A Song",
|
Name: "A Song",
|
||||||
MBID: "mbid444",
|
MBID: "mbid444",
|
||||||
|
|
@ -385,7 +387,7 @@ var _ = Describe("Agents", func() {
|
||||||
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
Expect(mock.Args).To(HaveExactElements("123", "test", "mb123", 2))
|
||||||
})
|
})
|
||||||
It("skips the agent if it returns an error", func() {
|
It("skips the agent if it returns an error", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevExternalArtistFetchMultiplier = 1 })
|
conf.Server.DevExternalArtistFetchMultiplier = 1
|
||||||
mock.Err = errors.New("error")
|
mock.Err = errors.New("error")
|
||||||
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
_, err := ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)
|
||||||
Expect(err).To(MatchError(ErrNotFound))
|
Expect(err).To(MatchError(ErrNotFound))
|
||||||
|
|
@ -398,7 +400,7 @@ var _ = Describe("Agents", func() {
|
||||||
Expect(mock.Args).To(BeEmpty())
|
Expect(mock.Args).To(BeEmpty())
|
||||||
})
|
})
|
||||||
It("fetches with multiplier", func() {
|
It("fetches with multiplier", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevExternalArtistFetchMultiplier = 2 })
|
conf.Server.DevExternalArtistFetchMultiplier = 2
|
||||||
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
Expect(ag.GetArtistTopSongs(ctx, "123", "test", "mb123", 2)).To(Equal([]Song{{
|
||||||
Name: "A Song",
|
Name: "A Song",
|
||||||
MBID: "mbid444",
|
MBID: "mbid444",
|
||||||
|
|
@ -517,7 +519,7 @@ var _ = Describe("Agents", func() {
|
||||||
|
|
||||||
Describe("ArtistImageAgents", func() {
|
Describe("ArtistImageAgents", func() {
|
||||||
It("returns only ArtistImageRetriever agents, named, in configured order", func() {
|
It("returns only ArtistImageRetriever agents, named, in configured order", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "artistImg,noImages,artistImg2" })
|
conf.Server.Agents = "artistImg,noImages,artistImg2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
result := ag.ArtistImageAgents()
|
result := ag.ArtistImageAgents()
|
||||||
|
|
@ -529,7 +531,7 @@ var _ = Describe("Agents", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is empty when external services are disabled", func() {
|
It("is empty when external services are disabled", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" }) // what disableExternalServices() sets when EnableExternalServices=false
|
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
Expect(ag.ArtistImageAgents()).To(BeEmpty())
|
Expect(ag.ArtistImageAgents()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -537,7 +539,7 @@ var _ = Describe("Agents", func() {
|
||||||
|
|
||||||
Describe("AlbumImageAgents", func() {
|
Describe("AlbumImageAgents", func() {
|
||||||
It("returns only AlbumImageRetriever agents, named, in configured order", func() {
|
It("returns only AlbumImageRetriever agents, named, in configured order", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "albumImg,noImages,albumImg2" })
|
conf.Server.Agents = "albumImg,noImages,albumImg2"
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
|
|
||||||
result := ag.AlbumImageAgents()
|
result := ag.AlbumImageAgents()
|
||||||
|
|
@ -549,7 +551,7 @@ var _ = Describe("Agents", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("is empty when external services are disabled", func() {
|
It("is empty when external services are disabled", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = "" }) // what disableExternalServices() sets when EnableExternalServices=false
|
conf.Server.Agents = "" // what disableExternalServices() sets when EnableExternalServices=false
|
||||||
ag = createAgents(ds, nil)
|
ag = createAgents(ds, nil)
|
||||||
Expect(ag.AlbumImageAgents()).To(BeEmpty())
|
Expect(ag.AlbumImageAgents()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ import (
|
||||||
|
|
||||||
// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
|
// externalName mirrors the normalization the aggregate provider applies, so agent searches match.
|
||||||
func externalName(name string) string {
|
func externalName(name string) string {
|
||||||
if conf.Server().DevPreserveUnicodeInExternalCalls {
|
if conf.Server.DevPreserveUnicodeInExternalCalls {
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
return str.Clear(name)
|
return str.Clear(name)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -64,7 +65,7 @@ func imageAgents(fakes ...*fakeImageAgent) *agents.Agents {
|
||||||
agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
|
agents.Register(fake.name, func(model.DataStore) agents.Interface { return fake })
|
||||||
names = append(names, fake.name)
|
names = append(names, fake.name)
|
||||||
}
|
}
|
||||||
conf.Update(func(c *conf.Options) { c.Agents = strings.Join(names, ",") })
|
conf.Server.Agents = strings.Join(names, ",")
|
||||||
return agents.GetAgents(&tests.MockDataStore{}, nil)
|
return agents.GetAgents(&tests.MockDataStore{}, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +76,7 @@ var _ = Describe("agent images", func() {
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
_, _ = w.Write([]byte("image-bytes"))
|
_, _ = w.Write([]byte("image-bytes"))
|
||||||
|
|
@ -198,7 +200,7 @@ var _ = Describe("agent images", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears typographic characters from the query name unless preserving unicode", func() {
|
It("clears typographic characters from the query name unless preserving unicode", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DevPreserveUnicodeInExternalCalls = false })
|
conf.Server.DevPreserveUnicodeInExternalCalls = false
|
||||||
a := &fakeImageAgent{name: "agentA"}
|
a := &fakeImageAgent{name: "agentA"}
|
||||||
ag := imageAgents(a)
|
ag := imageAgents(a)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}})
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← matched by cover.*
|
// └── cover.jpg ← matched by cover.*
|
||||||
It("returns the album-root cover", func() {
|
It("returns the album-root cover", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||||
|
|
@ -46,7 +46,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── cover.jpg
|
// │ └── cover.jpg
|
||||||
// └── cover.jpg ← should win (album-root fallback)
|
// └── cover.jpg ← should win (album-root fallback)
|
||||||
It("prefers the album-root cover over per-disc covers", func() {
|
It("prefers the album-root cover over per-disc covers", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
|
@ -77,7 +77,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── folder.jpg
|
// │ └── folder.jpg
|
||||||
// └── folder.jpg ← should win (album-root fallback)
|
// └── folder.jpg ← should win (album-root fallback)
|
||||||
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
It("prefers the album-root folder.jpg over per-disc folder.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── 01 - Track.mp3
|
// │ └── 01 - Track.mp3
|
||||||
// └── cover.jpg ← should win (parent-folder fallback)
|
// └── cover.jpg ← should win (parent-folder fallback)
|
||||||
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
It("uses the parent-folder cover for single-disc-subfolder albums", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
"Artist/Album/cover.jpg": smallPNG("album-root"),
|
||||||
|
|
@ -120,7 +120,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// │ └── folder.jpg
|
// │ └── folder.jpg
|
||||||
// └── cover.jpg ← should win (album-root)
|
// └── cover.jpg ← should win (album-root)
|
||||||
It("prefers the album-root cover.jpg", func() {
|
It("prefers the album-root cover.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
"Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"),
|
||||||
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
"Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"),
|
||||||
|
|
@ -139,7 +139,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
// ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded")
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("returns the embedded image", func() {
|
It("returns the embedded image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" })
|
conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
"Artist/Album/cover.jpg": smallPNG("external"),
|
"Artist/Album/cover.jpg": smallPNG("external"),
|
||||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
// └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded")
|
||||||
It("falls through to embedded artwork", func() {
|
It("falls through to embedded artwork", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "external, embedded" })
|
conf.Server.CoverArtPriority = "external, embedded"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}),
|
||||||
})
|
})
|
||||||
|
|
@ -177,7 +177,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── Cover.JPG ← matched case-insensitively by cover.*
|
// └── Cover.JPG ← matched case-insensitively by cover.*
|
||||||
It("matches case-insensitively against cover.*", func() {
|
It("matches case-insensitively against cover.*", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*, folder.*" })
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
|
"Artist/Album/Cover.JPG": smallPNG("case-insensitive"),
|
||||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── cover.jpg ← wins (no numeric suffix)
|
// ├── cover.jpg ← wins (no numeric suffix)
|
||||||
// └── cover.1.jpg
|
// └── cover.1.jpg
|
||||||
It("prefers the file without a numeric suffix", func() {
|
It("prefers the file without a numeric suffix", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("primary"),
|
"Artist/Album/cover.jpg": smallPNG("primary"),
|
||||||
|
|
@ -210,7 +210,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3 (no image files — settles absent)
|
// └── 01 - Track.mp3 (no image files — settles absent)
|
||||||
It("settles absent", func() {
|
It("settles absent", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*, folder.*" })
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
})
|
})
|
||||||
|
|
@ -228,7 +228,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── folder.jpg ← matched by folder.*
|
// └── folder.jpg ← matched by folder.*
|
||||||
It("falls through to folder.jpg", func() {
|
It("falls through to folder.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||||
|
|
@ -244,7 +244,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── front.jpg ← matched by front.*
|
// └── front.jpg ← matched by front.*
|
||||||
It("falls through to front.jpg", func() {
|
It("falls through to front.jpg", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/front.jpg": smallPNG("front"),
|
"Artist/Album/front.jpg": smallPNG("front"),
|
||||||
|
|
@ -262,7 +262,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── folder.jpg
|
// ├── folder.jpg
|
||||||
// └── front.jpg
|
// └── front.jpg
|
||||||
It("prefers cover.* (first in CoverArtPriority)", func() {
|
It("prefers cover.* (first in CoverArtPriority)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
@ -281,7 +281,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
// ├── folder.jpg ← wins (folder.* comes before front.*)
|
||||||
// └── front.jpg
|
// └── front.jpg
|
||||||
It("prefers folder.* over front.*", func() {
|
It("prefers folder.* over front.*", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/folder.jpg": smallPNG("folder"),
|
"Artist/Album/folder.jpg": smallPNG("folder"),
|
||||||
|
|
@ -300,7 +300,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── cover.1.jpg
|
// ├── cover.1.jpg
|
||||||
// └── cover.2.jpg
|
// └── cover.2.jpg
|
||||||
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
It("selects the unsuffixed file first regardless of numeric-suffix order", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "cover.*" })
|
conf.Server.CoverArtPriority = "cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.2.jpg": smallPNG("second"),
|
"Artist/Album/cover.2.jpg": smallPNG("second"),
|
||||||
|
|
@ -318,7 +318,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
// └── cover.jpg ← wins (unknown "bogus.*" is skipped)
|
||||||
It("skips the unknown pattern and falls through to the matching one", func() {
|
It("skips the unknown pattern and falls through to the matching one", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "bogus.*, cover.*" })
|
conf.Server.CoverArtPriority = "bogus.*, cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
@ -340,7 +340,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("does not use the artist image as album art", func() {
|
It("does not use the artist image as album art", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||||
|
|
@ -366,7 +366,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("does not use the artist image as album art for the spread album", func() {
|
It("does not use the artist image as album art for the spread album", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
"Artist/folder.jpg": smallPNG("artist-thumbnail"),
|
||||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||||
|
|
@ -392,7 +392,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── CD2/
|
// └── CD2/
|
||||||
// └── 01 - Track.mp3
|
// └── 01 - Track.mp3
|
||||||
It("does not adopt the library-root image as album art", func() {
|
It("does not adopt the library-root image as album art", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"cover.jpg": smallPNG("library-root"),
|
"cover.jpg": smallPNG("library-root"),
|
||||||
"CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}),
|
"CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"album": "Rootless", "disc": "1"}),
|
||||||
|
|
@ -412,7 +412,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← should win
|
// └── cover.jpg ← should win
|
||||||
It("prefers the album's own cover over the shallower artist-folder cover", func() {
|
It("prefers the album's own cover over the shallower artist-folder cover", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
|
|
@ -435,7 +435,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// └── Album B/
|
// └── Album B/
|
||||||
// └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
|
// └── 01 - Track.mp3 (other-album audio: rejects the artist folder as a root)
|
||||||
It("prefers the album's own art over the artist image", func() {
|
It("prefers the album's own art over the artist image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = defaultCoverPriority })
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/cover.jpg": smallPNG("artist-image"),
|
"Artist/cover.jpg": smallPNG("artist-image"),
|
||||||
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
"Artist/Album A/01 - Track.mp3": trackFile(1, "Track A1", map[string]any{"album": "Album A", "albumartist": "Artist"}),
|
||||||
|
|
@ -458,7 +458,7 @@ var _ = Describe("Album artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 (no embedded picture)
|
// ├── 01 - Track.mp3 (no embedded picture)
|
||||||
// └── cover.jpg ← wins (embedded skipped, falls through)
|
// └── cover.jpg ← wins (embedded skipped, falls through)
|
||||||
It("falls through to the next priority entry", func() {
|
It("falls through to the next priority entry", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.CoverArtPriority = "embedded, cover.*" })
|
conf.Server.CoverArtPriority = "embedded, cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3
|
// └── 01 - Track.mp3
|
||||||
It("returns the artist.* image from the artist folder", func() {
|
It("returns the artist.* image from the artist folder", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||||
|
|
@ -46,7 +46,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── artist.jpg ← matched by album/artist.*
|
// └── artist.jpg ← matched by album/artist.*
|
||||||
It("falls through to album/artist.* and returns that image", func() {
|
It("falls through to album/artist.* and returns that image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
"Artist/Album/artist.jpg": smallPNG("album-artist"),
|
||||||
|
|
@ -63,7 +63,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── artist.jpg
|
// └── artist.jpg
|
||||||
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
|
It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*, album/artist.*, external" })
|
conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||||
|
|
@ -80,7 +80,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3
|
// └── 01 - Track.mp3
|
||||||
It("still resolves the artist folder and returns artist.*", func() {
|
It("still resolves the artist folder and returns artist.*", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.ArtistArtPriority = "artist.*" })
|
conf.Server.ArtistArtPriority = "artist.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}),
|
||||||
"Artist/artist.jpg": smallPNG("artist-folder"),
|
"Artist/artist.jpg": smallPNG("artist-folder"),
|
||||||
|
|
@ -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"}),
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── disc1.jpg ← matched by disc*.*
|
// └── disc1.jpg ← matched by disc*.*
|
||||||
It("returns the disc1.jpg image (matched as disc*.*)", func() {
|
It("returns the disc1.jpg image (matched as disc*.*)", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" })
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/disc1.jpg": smallPNG("disc1-image"),
|
"Artist/Album/disc1.jpg": smallPNG("disc1-image"),
|
||||||
|
|
@ -38,10 +38,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// └── Album/
|
// └── Album/
|
||||||
// └── 01 - Track.mp3 (no disc or album art — nothing to serve)
|
// └── 01 - Track.mp3 (no disc or album art — nothing to serve)
|
||||||
It("reports the disc lookup as unavailable", func() {
|
It("reports the disc lookup as unavailable", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||||
c.DiscArtPriority = "disc*.*, cd*.*"
|
conf.Server.CoverArtPriority = "cover.*, folder.*"
|
||||||
c.CoverArtPriority = "cover.*, folder.*"
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
})
|
})
|
||||||
|
|
@ -56,10 +54,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg ← album-level fallback (no disc art present)
|
// └── cover.jpg ← album-level fallback (no disc art present)
|
||||||
It("falls back to the album cover", func() {
|
It("falls back to the album cover", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = "disc*.*, cd*.*"
|
||||||
c.DiscArtPriority = "disc*.*, cd*.*"
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
"Artist/Album/cover.jpg": smallPNG("album-cover"),
|
||||||
|
|
@ -76,7 +72,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── disc1.jpg ← matches request for disc 1
|
// ├── disc1.jpg ← matches request for disc 1
|
||||||
// └── disc10.jpg
|
// └── disc10.jpg
|
||||||
It("matches the requested disc number, not a higher-numbered one", func() {
|
It("matches the requested disc number, not a higher-numbered one", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "disc*.*" })
|
conf.Server.DiscArtPriority = "disc*.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "Track"),
|
||||||
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
|
"Artist/Album/disc1.jpg": smallPNG("disc-one"),
|
||||||
|
|
@ -97,7 +93,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── disc2.jpg ← matches request for disc 2
|
// └── disc2.jpg ← matches request for disc 2
|
||||||
It("returns the requested disc's image", func() {
|
It("returns the requested disc's image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "disc*.*" })
|
conf.Server.DiscArtPriority = "disc*.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -122,7 +118,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cd2.png ← matched by cd*.* for disc 2
|
// └── cd2.png ← matched by cd*.* for disc 2
|
||||||
It("matches via the cd*.* pattern", func() {
|
It("matches via the cd*.* pattern", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = defaultDiscPriority })
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -144,7 +140,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── cover.jpg
|
// └── cover.jpg
|
||||||
It("falls through to cover.* inside the disc folder", func() {
|
It("falls through to cover.* inside the disc folder", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = defaultDiscPriority })
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -167,10 +163,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// │ └── cd2.png (ignored — DiscArtPriority is empty)
|
// │ └── cd2.png (ignored — DiscArtPriority is empty)
|
||||||
// └── cover.jpg ← used for every disc (album-level fallback)
|
// └── cover.jpg ← used for every disc (album-level fallback)
|
||||||
It("skips every disc-level source and returns the album cover", func() {
|
It("skips every disc-level source and returns the album cover", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = ""
|
||||||
c.DiscArtPriority = ""
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
"Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}),
|
||||||
|
|
@ -200,10 +194,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// │ └── 02 - Track.mp3
|
// │ └── 02 - Track.mp3
|
||||||
// └── cover.jpg (album-level fallback, unused here)
|
// └── cover.jpg (album-level fallback, unused here)
|
||||||
It("matches the per-disc image for each disc", func() {
|
It("matches the per-disc image for each disc", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
"Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}),
|
||||||
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
|
"Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}),
|
||||||
|
|
@ -227,7 +219,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||||
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
|
// └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword
|
||||||
It("selects the subtitle-named image", func() {
|
It("selects the subtitle-named image", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "discsubtitle" })
|
conf.Server.DiscArtPriority = "discsubtitle"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||||
"Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"),
|
"Artist/Album/Bonus Tracks.jpg": smallPNG("bonus-tracks"),
|
||||||
|
|
@ -250,10 +242,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// │ └── folder.jpg
|
// │ └── folder.jpg
|
||||||
// └── ... (12 discs)
|
// └── ... (12 discs)
|
||||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
discNames := []string{
|
discNames := []string{
|
||||||
"Disc 01 (Birth of the Dead - The Studio Sides)",
|
"Disc 01 (Birth of the Dead - The Studio Sides)",
|
||||||
"Disc 02 (Birth of the Dead - The Live Sides)",
|
"Disc 02 (Birth of the Dead - The Live Sides)",
|
||||||
|
|
@ -304,10 +294,8 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3
|
// ├── 01 - Track.mp3
|
||||||
// └── folder.jpg
|
// └── folder.jpg
|
||||||
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() {
|
||||||
conf.Update(func(c *conf.Options) {
|
conf.Server.DiscArtPriority = defaultDiscPriority
|
||||||
c.DiscArtPriority = defaultDiscPriority
|
conf.Server.CoverArtPriority = defaultCoverPriority
|
||||||
c.CoverArtPriority = defaultCoverPriority
|
|
||||||
})
|
|
||||||
layout := fstest.MapFS{
|
layout := fstest.MapFS{
|
||||||
"Album/cover.jpg": smallPNG("album-root-cover"),
|
"Album/cover.jpg": smallPNG("album-root-cover"),
|
||||||
}
|
}
|
||||||
|
|
@ -333,7 +321,7 @@ var _ = Describe("Disc artwork resolution", func() {
|
||||||
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
// ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks")
|
||||||
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
|
// └── cover.jpg ← wins (discsubtitle has no match, falls through)
|
||||||
It("falls through to the next priority entry", func() {
|
It("falls through to the next priority entry", func() {
|
||||||
conf.Update(func(c *conf.Options) { c.DiscArtPriority = "discsubtitle, cover.*" })
|
conf.Server.DiscArtPriority = "discsubtitle, cover.*"
|
||||||
setLayout(fstest.MapFS{
|
setLayout(fstest.MapFS{
|
||||||
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
"Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}),
|
||||||
"Artist/Album/cover.jpg": smallPNG("cover"),
|
"Artist/Album/cover.jpg": smallPNG("cover"),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor
|
_ "github.com/navidrome/navidrome/adapters/gotaglib" // registers the "taglib" local-storage extractor
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/artwork"
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
_ "github.com/navidrome/navidrome/core/storage/local"
|
_ "github.com/navidrome/navidrome/core/storage/local"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
|
|
@ -28,8 +27,6 @@ func TestArtworkE2E(t *testing.T) {
|
||||||
RunSpecs(t, "Artwork Pipeline E2E Suite")
|
RunSpecs(t, "Artwork Pipeline E2E Suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = configtest.RestoreAfterEachSpec()
|
|
||||||
|
|
||||||
// Fixtures relative to the project root (tests.Init chdirs there).
|
// Fixtures relative to the project root (tests.Init chdirs there).
|
||||||
const (
|
const (
|
||||||
coverFixture = "tests/fixtures/artist/an-album/cover.jpg"
|
coverFixture = "tests/fixtures/artist/an-album/cover.jpg"
|
||||||
|
|
|
||||||
|
|
@ -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")))
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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].
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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) })
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
3
core/external/extdata_suite_test.go
vendored
3
core/external/extdata_suite_test.go
vendored
|
|
@ -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()
|
|
||||||
|
|
|
||||||
8
core/external/provider.go
vendored
8
core/external/provider.go
vendored
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
core/external/provider_topsongs_test.go
vendored
4
core/external/provider_topsongs_test.go
vendored
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
10
core/external/provider_updateartistinfo_test.go
vendored
10
core/external/provider_updateartistinfo_test.go
vendored
|
|
@ -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"},
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 }
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue