mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-09 03:38:48 +01:00
Compare commits
50 commits
mp3-xing-d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5b6805b31 |
||
|
|
97ae17cab3 | ||
|
|
a4f75cf6c4 |
||
|
|
6a38dc988b |
||
|
|
7c1e68a1e4 |
||
|
|
755da3ea59 |
||
|
|
c9c70c952b |
||
|
|
563c5c72a1 |
||
|
|
2a57570e90 |
||
|
|
ec2e0a9ea9 |
||
|
|
56cc32aede |
||
|
|
df77c462e8 |
||
|
|
a45adbbb2e |
||
|
|
be201a2d79 |
||
|
|
30663f7d4f |
||
|
|
8e306b1f61 |
||
|
|
4ae97ab59e |
||
|
|
803b2da8ee | ||
|
|
74e96a2bb7 | ||
|
|
497dfe2586 |
||
|
|
b70ba3a49e | ||
|
|
d5a2e1e813 | ||
|
|
b4d2c3e4af | ||
|
|
22ce85e5d5 | ||
|
|
aea60c6e99 | ||
|
|
b78c963931 |
||
|
|
52135913d4 |
||
|
|
caa2f8a0c0 |
||
|
|
95f67d2c4e |
||
|
|
758e64c999 |
||
|
|
0e1893530b |
||
|
|
e03ce87177 |
||
|
|
3a31f702b5 |
||
|
|
a62d1629f0 |
||
|
|
612c8290f9 |
||
|
|
ab5869123d |
||
|
|
4cdffd5633 |
||
|
|
ce484083bf |
||
|
|
46c432719f |
||
|
|
c22ce9ebb2 |
||
|
|
bb7d81a5ea |
||
|
|
4113d954ca |
||
|
|
6b05189190 | ||
|
|
b293b96256 |
||
|
|
83fff44c82 |
||
|
|
659d067aba |
||
|
|
336fa482f8 |
||
|
|
3f89baaec8 |
||
|
|
a0c4160376 | ||
|
|
69b496383d |
533 changed files with 15895 additions and 9318 deletions
100
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
100
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,98 +1,74 @@
|
||||||
name: Bug Report
|
name: Bug report
|
||||||
description: Before opening a new issue, please search to see if an issue already exists for the bug you encountered.
|
description: Report something that is not working as expected. You do not need to know the cause or how to fix it.
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug", "triage"]
|
labels: ["triage"]
|
||||||
#assignees:
|
#assignees:
|
||||||
# - deluan
|
# - deluan
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
value: |
|
value: |
|
||||||
### Thanks for taking the time to fill out this bug report!
|
Thanks for reporting a problem. For a possible security vulnerability, use [private reporting](https://github.com/navidrome/navidrome/security/advisories/new).
|
||||||
- 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) to see if an issue already exists for the bug I've encountered
|
- 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.
|
||||||
|
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: Version
|
label: Navidrome version
|
||||||
description: What version of Navidrome are you running? (please try upgrading first, as your issue may have been fixed already).
|
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.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
id: expected
|
||||||
attributes:
|
attributes:
|
||||||
label: Current Behavior
|
label: Expected behavior and why
|
||||||
description: A concise description of what you're experiencing.
|
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.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
id: steps
|
||||||
attributes:
|
attributes:
|
||||||
label: Expected Behavior
|
label: Steps to reproduce
|
||||||
description: A concise description of what you expected to happen.
|
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.
|
||||||
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: Environment
|
label: Relevant environment
|
||||||
description: |
|
description: >-
|
||||||
examples:
|
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.
|
||||||
- **OS**: Ubuntu 20.04
|
|
||||||
- **Browser**: Chrome 110.0.5481.177 on Windows 11
|
|
||||||
- **Client**: DSub 5.5.1
|
|
||||||
value: |
|
|
||||||
- OS:
|
|
||||||
- Browser:
|
|
||||||
- Client:
|
|
||||||
render: markdown
|
|
||||||
- type: dropdown
|
|
||||||
id: distribution
|
|
||||||
attributes:
|
|
||||||
label: How Navidrome is installed?
|
|
||||||
multiple: false
|
|
||||||
options:
|
|
||||||
- Docker
|
|
||||||
- Binary (from downloads page)
|
|
||||||
- Package
|
|
||||||
- Built from sources
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: config
|
id: config-logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Configuration
|
label: Relevant configuration and logs
|
||||||
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.
|
description: >-
|
||||||
render: toml
|
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).
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: additional-context
|
||||||
attributes:
|
attributes:
|
||||||
label: Relevant log output
|
label: Screenshots or additional context
|
||||||
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))
|
description: Add screenshots, recordings, links, or other observations that help explain the problem. Remove private information from attachments as well.
|
||||||
render: shell
|
validations:
|
||||||
- type: textarea
|
required: false
|
||||||
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 for new features
|
- name: Ideas and behavior changes
|
||||||
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
url: https://github.com/navidrome/navidrome/discussions/categories/ideas
|
||||||
about: This is the place to share and discuss new ideas and potentially new features.
|
about: Describe the problem, intended outcome, and use case before starting an implementation.
|
||||||
- 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: This is the place to ask questions.
|
about: Ask for help with setup, configuration, or using Navidrome.
|
||||||
|
|
|
||||||
21
.github/pull_request_template.md
vendored
21
.github/pull_request_template.md
vendored
|
|
@ -1,8 +1,13 @@
|
||||||
### Description
|
### Description
|
||||||
<!-- Please provide a clear and concise description of what this PR does and why it is needed. -->
|
<!-- Please provide a clear and concise description of what this PR does and why it is needed. AI tools may help you draft, edit, or translate it, but review the final text and make sure you agree with it. Please be mindful of the maintainer's time. -->
|
||||||
|
|
||||||
|
**AI use** (check one):
|
||||||
|
- [ ] Yes, AI helped generate or modify code or tests, draft substantive PR text, or create this PR
|
||||||
|
- [ ] No
|
||||||
|
<!-- Tool names and prompts are not required. -->
|
||||||
|
|
||||||
### Related Issues
|
### Related Issues
|
||||||
<!-- List any related issues, e.g., "Fixes #123" or "Related to #456". -->
|
<!-- 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. -->
|
||||||
|
|
||||||
### Type of Change
|
### Type of Change
|
||||||
- [ ] Bug fix
|
- [ ] Bug fix
|
||||||
|
|
@ -12,27 +17,29 @@
|
||||||
- [ ] Other (please describe):
|
- [ ] Other (please describe):
|
||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
Please review and check all that apply:
|
Please review and check all that apply. Leave unverified items unchecked and explain them under How to Test.
|
||||||
|
|
||||||
- [ ] 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, and expected results. -->
|
<!-- 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. -->
|
||||||
|
|
||||||
### 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? -->
|
<!-- 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. -->
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
**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.
|
- If your PR is large, consider breaking it into smaller PRs within the agreed design and scope.
|
||||||
- Tag the maintainer if you need a prompt review.
|
- Review timing depends on maintainer availability; please do not tag the maintainer for faster 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.
|
||||||
-->
|
-->
|
||||||
34
.github/workflows/pipeline.yml
vendored
34
.github/workflows/pipeline.yml
vendored
|
|
@ -92,8 +92,23 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Resolve OpenAPI tool versions
|
||||||
|
id: api-tools
|
||||||
|
run: echo "key=$(grep -E '^(VACUUM|OAPI_CODEGEN|OASDIFF)_VERSION' Makefile | tr -d ' \n')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache OpenAPI tools
|
||||||
|
uses: actions/cache@v6
|
||||||
|
with:
|
||||||
|
path: bin
|
||||||
|
key: api-tools-${{ runner.os }}-${{ steps.api-tools.outputs.key }}
|
||||||
|
|
||||||
|
- name: Lint OpenAPI spec
|
||||||
|
run: make api-lint
|
||||||
|
|
||||||
- name: Run go generate
|
- name: Run go generate
|
||||||
run: go generate ./...
|
run: |
|
||||||
|
make api-gen
|
||||||
|
go generate ./...
|
||||||
- name: Verify no changes from go generate
|
- name: Verify no changes from go generate
|
||||||
run: |
|
run: |
|
||||||
git status --porcelain
|
git status --porcelain
|
||||||
|
|
@ -102,6 +117,12 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: Check for breaking OpenAPI changes
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
git fetch --no-tags --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||||
|
make api-diff API_DIFF_BASE=${{ github.event.pull_request.base.sha }}
|
||||||
|
|
||||||
validate-migrations:
|
validate-migrations:
|
||||||
name: Validate DB migrations
|
name: Validate DB migrations
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
@ -498,13 +519,18 @@ 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
|
name: Push to Docker Hub (${{ matrix.repository }})
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -520,7 +546,7 @@ jobs:
|
||||||
id: docker
|
id: docker
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
hub_repository: ${{ vars.DOCKER_HUB_REPO }}
|
hub_repository: ${{ matrix.repository }}
|
||||||
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
|
||||||
|
|
@ -537,7 +563,7 @@ jobs:
|
||||||
|
|
||||||
- name: Inspect image in Docker Hub
|
- name: Inspect image in Docker Hub
|
||||||
run: |
|
run: |
|
||||||
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
|
docker buildx imagetools inspect ${{ matrix.repository }}:${{ steps.docker.outputs.version }}
|
||||||
|
|
||||||
cleanup-digests:
|
cleanup-digests:
|
||||||
name: Cleanup digest artifacts
|
name: Cleanup digest artifacts
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ linters:
|
||||||
- asciicheck
|
- asciicheck
|
||||||
- bidichk
|
- bidichk
|
||||||
- bodyclose
|
- bodyclose
|
||||||
|
- containedctx
|
||||||
- copyloopvar
|
- copyloopvar
|
||||||
- dogsled
|
- dogsled
|
||||||
- durationcheck
|
- durationcheck
|
||||||
|
|
@ -57,6 +58,9 @@ linters:
|
||||||
- gosec
|
- gosec
|
||||||
path: _test\.go
|
path: _test\.go
|
||||||
text: "G703"
|
text: "G703"
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- containedctx
|
||||||
- path-except: 'db/migrations/'
|
- path-except: 'db/migrations/'
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
|
|
|
||||||
111
CONTRIBUTING.md
111
CONTRIBUTING.md
|
|
@ -1,93 +1,66 @@
|
||||||
# Navidrome Contribution Guide
|
# Navidrome Contribution Guide
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
- [Asking Support Questions](#asking-support-questions)
|
## Support and conduct
|
||||||
- [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).
|
||||||
|
|
||||||
## Asking Support Questions
|
## Issues and ideas
|
||||||
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.
|
|
||||||
|
|
||||||
## Code of Conduct
|
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.
|
||||||
Please read the following [Code of Conduct](https://github.com/navidrome/navidrome/blob/master/CODE_OF_CONDUCT.md).
|
|
||||||
|
|
||||||
## Issues
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
**Before opening a new issue, please check if the issue has not been already made by searching
|
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).
|
||||||
the [issues](https://github.com/navidrome/navidrome/issues)**
|
|
||||||
|
|
||||||
## Pull requests
|
## Agree on the change before implementation
|
||||||
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.
|
|
||||||
|
|
||||||
### Commit Conventions
|
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.
|
||||||
Each commit message must adhere to the following format:
|
|
||||||
```
|
|
||||||
<type>(scope): <description>
|
|
||||||
|
|
||||||
[optional body]
|
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.
|
||||||
```
|
|
||||||
This improves the readability of the messages
|
|
||||||
|
|
||||||
#### Type
|
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.
|
||||||
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
|
|
||||||
|
|
||||||
If there is a breaking change in your Pull Request, please add `BREAKING CHANGE` in the optional body section
|
## Review your work before opening a PR
|
||||||
|
|
||||||
#### Scope
|
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.
|
||||||
The file or folder where the changes are made. If there are more than one, you can mention any
|
|
||||||
|
|
||||||
#### Description
|
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.
|
||||||
A short description of the issue
|
|
||||||
|
|
||||||
#### Issue number
|
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.
|
||||||
The issue fixed by this Pull Request.
|
|
||||||
|
|
||||||
The body is optional. It may contain short description of changes made.
|
## AI Disclosure
|
||||||
|
|
||||||
Following all the guidelines an ideal commit will look like:
|
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.
|
||||||
```
|
|
||||||
git commit --signoff -m "feat(themes): New-theme - #834"
|
|
||||||
```
|
|
||||||
|
|
||||||
After committing, push your commits to your forked branch and create a Pull Request from there.
|
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.
|
||||||
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>
|
|
||||||
|
|
||||||
Description (What does the pull request do)
|
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.
|
||||||
|
|
||||||
Changes (What changes were made )
|
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.
|
||||||
|
|
||||||
Screenshots or Videos
|
## Keep the result maintainable
|
||||||
|
|
||||||
Related Issues and Pull Requests(if any)
|
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.
|
||||||
|
|
||||||
```
|
The maintainer must be able to understand and maintain the result without depending on your future availability. An offer to provide ongoing support does not replace this requirement. A working implementation may still be declined if its benefit does not justify its complexity or maintenance cost.
|
||||||
|
|
||||||
|
<a id="pull-requests"></a>
|
||||||
|
|
||||||
|
## Submitting and review
|
||||||
|
|
||||||
|
- Check existing [open and closed PRs](https://github.com/navidrome/navidrome/pulls?q=is%3Apr) to avoid duplicate work. If the issue author or another contributor has offered to submit a fix, ask in the issue before you start.
|
||||||
|
- Use the [development environment instructions](https://www.navidrome.org/docs/developers/dev-environment/). Work on a branch in your fork.
|
||||||
|
- Complete the [PR template](.github/pull_request_template.md) with the scope agreement or why agreement is not needed, verification evidence, and maintenance implications. Keep each PR focused.
|
||||||
|
- Follow the [commit conventions and DCO requirements](#commit-conventions-and-dco) below.
|
||||||
|
|
||||||
|
Review and merge are discretionary. Proposals may be declined, simplified, split, or deferred. PRs that do not fit Navidrome's direction, the agreed scope, or the maintainer's capacity to maintain them may be closed without detailed code review or prolonged discussion. PRs lacking evidence or left with unresolved questions may also be closed. Agreement to pursue work does not guarantee review or merge. Please do not tag the maintainer, send private messages, or post status requests to obtain faster review.
|
||||||
|
|
||||||
|
## Commit conventions and DCO
|
||||||
|
|
||||||
|
Use this commit format: `<type>(scope): <description>`, with an optional body.
|
||||||
|
|
||||||
|
Allowed types: `feat`, `fix`, `sec`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `revert`, `chore`.
|
||||||
|
|
||||||
|
Use a relevant file or folder as the scope and a concise description. Reference the related issue when one exists, and include `BREAKING CHANGE` in the body for breaking changes. Sign off every commit using `git commit --signoff`. These requirements apply equally to AI-assisted work.
|
||||||
|
|
|
||||||
51
Makefile
51
Makefile
|
|
@ -18,9 +18,13 @@ 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 ?= deluan/navidrome:develop
|
DOCKER_TAG ?= navidrome/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.13.2
|
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||||
|
VACUUM_VERSION ?= v0.30.6
|
||||||
|
OAPI_CODEGEN_VERSION ?= v2.8.0
|
||||||
|
OASDIFF_VERSION ?= v1.32.1
|
||||||
|
API_DIFF_BASE ?= origin/master
|
||||||
|
|
||||||
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
|
||||||
|
|
||||||
|
|
@ -92,6 +96,45 @@ install-golangci-lint: ##@Development Install golangci-lint if not present
|
||||||
fi
|
fi
|
||||||
.PHONY: install-golangci-lint
|
.PHONY: install-golangci-lint
|
||||||
|
|
||||||
|
install-api-tools: ##@Development Install OpenAPI tools (vacuum, oapi-codegen, oasdiff) into ./bin
|
||||||
|
@STAMP=bin/.api-tools-$(VACUUM_VERSION)-$(OAPI_CODEGEN_VERSION)-$(OASDIFF_VERSION); \
|
||||||
|
if [ ! -f $$STAMP ] || [ ! -x bin/vacuum ] || [ ! -x bin/oapi-codegen ] || [ ! -x bin/oasdiff ]; then \
|
||||||
|
echo "Installing OpenAPI tools..."; \
|
||||||
|
GOBIN=$(CURDIR)/bin go install github.com/daveshanley/vacuum@$(VACUUM_VERSION) && \
|
||||||
|
GOBIN=$(CURDIR)/bin go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@$(OAPI_CODEGEN_VERSION) && \
|
||||||
|
GOBIN=$(CURDIR)/bin go install github.com/oasdiff/oasdiff@$(OASDIFF_VERSION) && \
|
||||||
|
rm -f bin/.api-tools-* && touch $$STAMP; \
|
||||||
|
fi
|
||||||
|
.PHONY: install-api-tools
|
||||||
|
|
||||||
|
api-lint: install-api-tools ##@Development Lint the OpenAPI spec
|
||||||
|
./bin/vacuum lint -r api/.vacuum.yaml -d -q -b --fail-severity error api/openapi/openapi.yaml
|
||||||
|
.PHONY: api-lint
|
||||||
|
|
||||||
|
api-bundle: install-api-tools ##@Development Bundle the multi-file OpenAPI spec into api/bundled
|
||||||
|
./bin/vacuum bundle -q --composed -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.yaml
|
||||||
|
./bin/vacuum bundle -q --composed --format json -p api/openapi api/openapi/openapi.yaml api/bundled/openapi.json
|
||||||
|
.PHONY: api-bundle
|
||||||
|
|
||||||
|
api-gen: api-bundle ##@Development Generate the API v1 server code from the bundled spec
|
||||||
|
./bin/oapi-codegen -config server/apiv1/oapi-codegen.yaml api/bundled/openapi.json
|
||||||
|
.PHONY: api-gen
|
||||||
|
|
||||||
|
api-diff: api-bundle ##@Development Fail on breaking OpenAPI changes against the merge-base with $(API_DIFF_BASE)
|
||||||
|
@git rev-parse --verify --quiet $(API_DIFF_BASE)^{commit} >/dev/null || { echo "Base ref $(API_DIFF_BASE) not found; set API_DIFF_BASE"; exit 1; }; \
|
||||||
|
BASE="$$(git merge-base HEAD $(API_DIFF_BASE) 2>/dev/null)"; \
|
||||||
|
if [ -z "$$BASE" ]; then \
|
||||||
|
echo "No merge-base with $(API_DIFF_BASE); falling back to its tip"; \
|
||||||
|
BASE=$(API_DIFF_BASE); \
|
||||||
|
fi; \
|
||||||
|
if git cat-file -e $$BASE:api/bundled/openapi.json 2>/dev/null; then \
|
||||||
|
git show $$BASE:api/bundled/openapi.json > $(CURDIR)/bin/api-base.json && \
|
||||||
|
./bin/oasdiff breaking $(CURDIR)/bin/api-base.json api/bundled/openapi.json --fail-on ERR --severity-levels api/.oasdiff-levels.txt; \
|
||||||
|
else \
|
||||||
|
echo "No bundled spec at $$BASE; skipping breaking-change check"; \
|
||||||
|
fi
|
||||||
|
.PHONY: api-diff
|
||||||
|
|
||||||
lint: install-golangci-lint ##@Development Lint Go code
|
lint: install-golangci-lint ##@Development Lint Go code
|
||||||
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
PATH=./bin:$$PATH golangci-lint run --timeout 5m
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
@ -111,7 +154,7 @@ wire: check_go_env ##@Development Update Dependency Injection
|
||||||
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
go tool wire gen -tags="$$(echo '$(GO_BUILD_TAGS)' | tr ',' ' ')" ./...
|
||||||
.PHONY: wire
|
.PHONY: wire
|
||||||
|
|
||||||
gen: check_go_env ##@Development Run go generate for code generation
|
gen: check_go_env api-gen ##@Development Run go generate for code generation
|
||||||
go generate ./...
|
go generate ./...
|
||||||
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
cd plugins/cmd/ndpgen && go run . -shared-types -input=../../types -output=../../pdk -go -rust
|
||||||
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
cd plugins/cmd/ndpgen && go run . -host-wrappers -input=../../host -package=host -shared=../../types
|
||||||
|
|
@ -180,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 `deluan/navidrome:develop`, override with DOCKER_TAG var. Use IMAGE_PLATFORMS to specify target platforms
|
docker-image: ##@Cross_Compilation Build Docker image, tagged as `navidrome/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/deluan/navidrome)
|
[](https://hub.docker.com/r/navidrome/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,9 +21,11 @@ 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 need/want a new feature, find a bug or think of any way to improve Navidrome,
|
__Any feedback is welcome!__ If you find a bug, please file a [GitHub issue](https://github.com/navidrome/navidrome/issues/new/choose).
|
||||||
please file a [GitHub issue](https://github.com/navidrome/navidrome/issues) or join the discussion in our
|
If you need/want a new feature or think of any way to improve Navidrome, please share it in
|
||||||
[Subreddit](https://www.reddit.com/r/navidrome/). If you want to contribute to the project in any other way
|
[Discussions](https://github.com/navidrome/navidrome/discussions/categories/ideas) or join the discussion in our
|
||||||
|
[Subreddit](https://www.reddit.com/r/navidrome/). Before contributing code, please read the
|
||||||
|
[Contribution Guide](CONTRIBUTING.md). If you want to contribute to the project in any other way
|
||||||
([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
|
||||||
|
|
@ -35,11 +37,18 @@ See instructions on the [project's website](https://www.navidrome.org/docs/insta
|
||||||
|
|
||||||
## Cloud Hosting
|
## Cloud Hosting
|
||||||
|
|
||||||
[PikaPods](https://www.pikapods.com) has partnered with us to offer you an
|
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.
|
||||||
[officially supported, cloud-hosted solution](https://www.navidrome.org/docs/installation/managed/#pikapods).
|
|
||||||
A share of the revenue helps fund the development of Navidrome at no additional cost for you.
|
<a href="https://www.pikapods.com/pods?run=navidrome"><img src="resources/hosting/pikapods.svg" alt="Run on PikaPods" width="200" height="40"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://zenith.hosting/host/navidrome?ref=navidrome"><img src="resources/hosting/zenith.svg" alt="Deploy with Zenith" width="200" height="40"></a>
|
||||||
|
<br>
|
||||||
|
<a href="https://store.elfhosted.com/product/navidrome/"><img src="resources/hosting/elfhosted.svg" alt="Deploy on ElfHosted" width="200" height="40"></a>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[](https://www.pikapods.com/pods?run=navidrome)
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(1, "folderID"))
|
return new(metadata.ToMediaFile(model.Library{ID: 1}, "folderID"))
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
|
||||||
|
|
@ -357,7 +357,7 @@ var _ = Describe("lastfmAgent", func() {
|
||||||
var httpClient *tests.FakeHttpClient
|
var httpClient *tests.FakeHttpClient
|
||||||
var track *model.MediaFile
|
var track *model.MediaFile
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
client := newClient("API_KEY", "SECRET", httpClient)
|
client := newClient("API_KEY", "SECRET", httpClient)
|
||||||
agent = lastFMConstructor(ds)
|
agent = lastFMConstructor(ds)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ var _ = Describe("auth_router", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
storedSessionKey := func(userID string) string {
|
storedSessionKey := func(userID string) string {
|
||||||
key, _ := userProps.Get(userID, sessionKeyProperty)
|
key, _ := userProps.Get(GinkgoT().Context(), userID, sessionKeyProperty)
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ var _ = Describe("listenBrainzAgent", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
_ = ds.UserProps(ctx).Put("user-1", sessionKeyProperty, "SK-1")
|
_ = ds.UserProps().Put(ctx, "user-1", sessionKeyProperty, "SK-1")
|
||||||
httpClient = &tests.FakeHttpClient{}
|
httpClient = &tests.FakeHttpClient{}
|
||||||
agent = listenBrainzConstructor(ds)
|
agent = listenBrainzConstructor(ds)
|
||||||
agent.client = newClient("http://localhost:8080", httpClient)
|
agent.client = newClient("http://localhost:8080", httpClient)
|
||||||
|
|
|
||||||
1
api/.oasdiff-levels.txt
Normal file
1
api/.oasdiff-levels.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
response-property-enum-value-added INFO
|
||||||
155
api/.vacuum.yaml
Normal file
155
api/.vacuum.yaml
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
extends: [[spectral:oas, recommended]]
|
||||||
|
rules:
|
||||||
|
# vacuum's `enumeration` function mis-resolves hyphenated `then.field` names,
|
||||||
|
# so the value check below targets `x-module` via `given` instead.
|
||||||
|
nd-operation-x-module-required:
|
||||||
|
description: Every operation belongs to exactly one capability module.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]
|
||||||
|
then:
|
||||||
|
field: x-module
|
||||||
|
function: truthy
|
||||||
|
nd-operation-x-module:
|
||||||
|
description: Every operation's capability module is one of the known values.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]['x-module']
|
||||||
|
then:
|
||||||
|
function: enumeration
|
||||||
|
functionOptions:
|
||||||
|
values:
|
||||||
|
- core
|
||||||
|
- streaming
|
||||||
|
- download
|
||||||
|
- artwork
|
||||||
|
- lyrics
|
||||||
|
- transcoding
|
||||||
|
- annotations
|
||||||
|
- playback
|
||||||
|
- queue
|
||||||
|
- custom-tags
|
||||||
|
- grouping
|
||||||
|
- playlists
|
||||||
|
- smart-playlists
|
||||||
|
- sync
|
||||||
|
- events
|
||||||
|
- jukebox
|
||||||
|
- sharing
|
||||||
|
- radio
|
||||||
|
- admin
|
||||||
|
nd-operation-stability-level-required:
|
||||||
|
description: Every operation declares its stability level, which the breaking-change gate relies on.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]
|
||||||
|
then:
|
||||||
|
field: x-stability-level
|
||||||
|
function: truthy
|
||||||
|
nd-operation-stability-level:
|
||||||
|
description: Every operation's stability level is alpha, beta, or stable.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]['x-stability-level']
|
||||||
|
then:
|
||||||
|
function: enumeration
|
||||||
|
functionOptions:
|
||||||
|
values:
|
||||||
|
- alpha
|
||||||
|
- beta
|
||||||
|
- stable
|
||||||
|
nd-operation-required-fields:
|
||||||
|
description: Operations need a stable operationId, summary, description and tags.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][get,put,post,delete,patch]
|
||||||
|
then:
|
||||||
|
- field: operationId
|
||||||
|
function: truthy
|
||||||
|
- field: summary
|
||||||
|
function: truthy
|
||||||
|
- field: description
|
||||||
|
function: truthy
|
||||||
|
- field: tags
|
||||||
|
function: truthy
|
||||||
|
# Our schemas live in path/response files, not root components, so this
|
||||||
|
# walks every resolved `properties` map in the document via `$..` instead.
|
||||||
|
nd-schema-property-descriptions:
|
||||||
|
description: Every schema property is documented.
|
||||||
|
severity: error
|
||||||
|
given: $..properties[*]
|
||||||
|
then:
|
||||||
|
field: description
|
||||||
|
function: truthy
|
||||||
|
# patternProperties covers the full 4xx/5xx range; needs an explicit
|
||||||
|
# `properties` entry too, or `additionalProperties: false` rejects it.
|
||||||
|
nd-error-responses-are-problems:
|
||||||
|
description: 4xx and 5xx responses use application/problem+json.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*][*].responses
|
||||||
|
then:
|
||||||
|
function: schema
|
||||||
|
functionOptions:
|
||||||
|
forceValidationOnCurrentNode: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
patternProperties:
|
||||||
|
"^[45][0-9][0-9]$":
|
||||||
|
type: object
|
||||||
|
required: [content]
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
application/problem+json: {}
|
||||||
|
required: [application/problem+json]
|
||||||
|
additionalProperties: false
|
||||||
|
# Same filter limitation applies here: "is this a list endpoint" is expressed
|
||||||
|
# as a JSON Schema if/then on the operation object instead of a `given` filter.
|
||||||
|
nd-list-endpoints-paginate:
|
||||||
|
description: List endpoints declare the shared offset and limit parameters.
|
||||||
|
severity: error
|
||||||
|
given: $.paths[*].get
|
||||||
|
then:
|
||||||
|
function: schema
|
||||||
|
functionOptions:
|
||||||
|
forceValidationOnCurrentNode: true
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
if:
|
||||||
|
required: [responses]
|
||||||
|
properties:
|
||||||
|
responses:
|
||||||
|
type: object
|
||||||
|
required: ['200']
|
||||||
|
properties:
|
||||||
|
'200':
|
||||||
|
type: object
|
||||||
|
required: [content]
|
||||||
|
properties:
|
||||||
|
content:
|
||||||
|
type: object
|
||||||
|
required: [application/json]
|
||||||
|
properties:
|
||||||
|
application/json:
|
||||||
|
type: object
|
||||||
|
required: [schema]
|
||||||
|
properties:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [properties]
|
||||||
|
properties:
|
||||||
|
properties:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
then:
|
||||||
|
required: [parameters]
|
||||||
|
properties:
|
||||||
|
parameters:
|
||||||
|
type: array
|
||||||
|
allOf:
|
||||||
|
- contains:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
const: offset
|
||||||
|
- contains:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
const: limit
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package ffmpeg
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -9,9 +9,9 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFFMpeg(t *testing.T) {
|
func TestAPI(t *testing.T) {
|
||||||
tests.Init(t, true)
|
tests.Init(t, false)
|
||||||
log.SetLevel(log.LevelFatal)
|
log.SetLevel(log.LevelFatal)
|
||||||
RegisterFailHandler(Fail)
|
RegisterFailHandler(Fail)
|
||||||
RunSpecs(t, "FFMpeg Suite")
|
RunSpecs(t, "API Spec Suite")
|
||||||
}
|
}
|
||||||
262
api/bundled/openapi.json
Normal file
262
api/bundled/openapi.json
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
{
|
||||||
|
"openapi": "3.0.3",
|
||||||
|
"info": {
|
||||||
|
"title": "Navidrome API",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Navidrome API v1. Spec-first, additive within v1. Clients discover implemented\ncapability modules through `GET /server` and never sniff versions.\n\nEnums are open: new values may be added to any enum within v1. Clients must\naccept values they do not recognise instead of failing.\n\nEvery operation declares `x-stability-level`: `alpha` operations may change or\ndisappear without notice, `beta` and `stable` operations only change additively.\nA level is only ever raised, never lowered.\n\n`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods\nin its `Allow` header.\n",
|
||||||
|
"license": {
|
||||||
|
"name": "GPL-3.0",
|
||||||
|
"url": "https://www.gnu.org/licenses/gpl-3.0.html"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"url": "/api/v1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"name": "server",
|
||||||
|
"description": "Server discovery and the published OpenAPI document."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"/server": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getServerInfo",
|
||||||
|
"x-module": "core",
|
||||||
|
"x-stability-level": "alpha",
|
||||||
|
"tags": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"summary": "Describe the server",
|
||||||
|
"description": "Returns the public server description. No authentication required.\nAuthenticated requests will additionally receive the implemented capability modules\nonce authentication is available.\n",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Server description.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ServerInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"$ref": "#/components/responses/InternalError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openapi.json": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getOpenAPISpecJSON",
|
||||||
|
"x-module": "core",
|
||||||
|
"x-stability-level": "alpha",
|
||||||
|
"tags": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"summary": "Get the OpenAPI document (JSON)",
|
||||||
|
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The OpenAPI document.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"$ref": "#/components/headers/ETag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "OpenAPI 3.0 document."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"$ref": "#/components/responses/NotModified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/openapi.yaml": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getOpenAPISpecYAML",
|
||||||
|
"x-module": "core",
|
||||||
|
"x-stability-level": "alpha",
|
||||||
|
"tags": [
|
||||||
|
"server"
|
||||||
|
],
|
||||||
|
"summary": "Get the OpenAPI document (YAML)",
|
||||||
|
"description": "The bundled OpenAPI document of the running server version. Supports ETag revalidation.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "The OpenAPI document.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"$ref": "#/components/headers/ETag"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"application/yaml": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "OpenAPI 3.0 document."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"304": {
|
||||||
|
"$ref": "#/components/responses/NotModified"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"securitySchemes": {
|
||||||
|
"bearerAuth": {
|
||||||
|
"type": "http",
|
||||||
|
"scheme": "bearer",
|
||||||
|
"bearerFormat": "JWT",
|
||||||
|
"description": "Short-lived access token minted from a device grant. Not yet applied to any operation."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {
|
||||||
|
"ServerInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Public server description. Everything an add-server screen needs before login.",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"serverVersion",
|
||||||
|
"specVersion",
|
||||||
|
"setupRequired",
|
||||||
|
"loginMethods"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable server product name."
|
||||||
|
},
|
||||||
|
"serverVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Version of the running server build."
|
||||||
|
},
|
||||||
|
"specVersion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Version of the OpenAPI document this server implements."
|
||||||
|
},
|
||||||
|
"setupRequired": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "True until the first admin user has been created."
|
||||||
|
},
|
||||||
|
"loginMethods": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"password"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"Problem": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "RFC 9457 problem details, returned for every 4xx and 5xx response.",
|
||||||
|
"required": [
|
||||||
|
"title",
|
||||||
|
"status",
|
||||||
|
"code"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "URI reference identifying the problem type. Omitted while the problem carries no semantics\nbeyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their\nown semantics get their own URI; switch on `code` instead.\n"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Short human-readable summary, the same for all occurrences of this problem type."
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "HTTP status code of this response."
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable explanation specific to this occurrence. Omitted for internal errors."
|
||||||
|
},
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Machine-readable error code, and the value clients switch on. New codes may be added.",
|
||||||
|
"enum": [
|
||||||
|
"validation",
|
||||||
|
"unauthorized",
|
||||||
|
"forbidden",
|
||||||
|
"not_found",
|
||||||
|
"method_not_allowed",
|
||||||
|
"unavailable",
|
||||||
|
"internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Per-field failures. Present only when `code` is `validation`.",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ValidationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ValidationError": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "One field-level validation failure.",
|
||||||
|
"required": [
|
||||||
|
"field",
|
||||||
|
"message"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"field": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the offending query parameter, path parameter, or body field (dotted for nested)."
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Why the value was rejected."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"InternalError": {
|
||||||
|
"description": "Unexpected server failure. Details are in the server log.",
|
||||||
|
"content": {
|
||||||
|
"application/problem+json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/Problem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"NotModified": {
|
||||||
|
"description": "Not modified.",
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"$ref": "#/components/headers/ETag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"ETag": {
|
||||||
|
"description": "Entity tag for `If-None-Match` revalidation.",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
194
api/bundled/openapi.yaml
Normal file
194
api/bundled/openapi.yaml
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Navidrome API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||||
|
capability modules through `GET /server` and never sniff versions.
|
||||||
|
|
||||||
|
Enums are open: new values may be added to any enum within v1. Clients must
|
||||||
|
accept values they do not recognise instead of failing.
|
||||||
|
|
||||||
|
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||||
|
disappear without notice, `beta` and `stable` operations only change additively.
|
||||||
|
A level is only ever raised, never lowered.
|
||||||
|
|
||||||
|
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||||
|
in its `Allow` header.
|
||||||
|
license:
|
||||||
|
name: GPL-3.0
|
||||||
|
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
tags:
|
||||||
|
- name: server
|
||||||
|
description: Server discovery and the published OpenAPI document.
|
||||||
|
paths:
|
||||||
|
/server:
|
||||||
|
get:
|
||||||
|
operationId: getServerInfo
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Describe the server
|
||||||
|
description: |
|
||||||
|
Returns the public server description. No authentication required.
|
||||||
|
Authenticated requests will additionally receive the implemented capability modules
|
||||||
|
once authentication is available.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Server description.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ServerInfo'
|
||||||
|
'500':
|
||||||
|
$ref: '#/components/responses/InternalError'
|
||||||
|
/openapi.json:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecJSON
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (JSON)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: '#/components/headers/ETag'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: '#/components/responses/NotModified'
|
||||||
|
/openapi.yaml:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecYAML
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (YAML)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: '#/components/headers/ETag'
|
||||||
|
content:
|
||||||
|
application/yaml:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: '#/components/responses/NotModified'
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||||
|
schemas:
|
||||||
|
ServerInfo:
|
||||||
|
type: object
|
||||||
|
description: Public server description. Everything an add-server screen needs before login.
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
- serverVersion
|
||||||
|
- specVersion
|
||||||
|
- setupRequired
|
||||||
|
- loginMethods
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Human-readable server product name.
|
||||||
|
serverVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the running server build.
|
||||||
|
specVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the OpenAPI document this server implements.
|
||||||
|
setupRequired:
|
||||||
|
type: boolean
|
||||||
|
description: True until the first admin user has been created.
|
||||||
|
loginMethods:
|
||||||
|
type: array
|
||||||
|
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- password
|
||||||
|
Problem:
|
||||||
|
type: object
|
||||||
|
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||||
|
required:
|
||||||
|
- title
|
||||||
|
- status
|
||||||
|
- code
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||||
|
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||||
|
own semantics get their own URI; switch on `code` instead.
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
description: HTTP status code of this response.
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||||
|
enum:
|
||||||
|
- validation
|
||||||
|
- unauthorized
|
||||||
|
- forbidden
|
||||||
|
- not_found
|
||||||
|
- method_not_allowed
|
||||||
|
- unavailable
|
||||||
|
- internal
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
description: Per-field failures. Present only when `code` is `validation`.
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ValidationError'
|
||||||
|
ValidationError:
|
||||||
|
type: object
|
||||||
|
description: One field-level validation failure.
|
||||||
|
required:
|
||||||
|
- field
|
||||||
|
- message
|
||||||
|
properties:
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Why the value was rejected.
|
||||||
|
responses:
|
||||||
|
InternalError:
|
||||||
|
description: Unexpected server failure. Details are in the server log.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Problem'
|
||||||
|
NotModified:
|
||||||
|
description: Not modified.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: '#/components/headers/ETag'
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
description: Entity tag for `If-None-Match` revalidation.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
35
api/embed.go
Normal file
35
api/embed.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed bundled/openapi.json
|
||||||
|
var specJSON []byte
|
||||||
|
|
||||||
|
//go:embed bundled/openapi.yaml
|
||||||
|
var specYAML []byte
|
||||||
|
|
||||||
|
func SpecJSON() []byte {
|
||||||
|
return specJSON
|
||||||
|
}
|
||||||
|
|
||||||
|
func SpecYAML() []byte {
|
||||||
|
return specYAML
|
||||||
|
}
|
||||||
|
|
||||||
|
var specVersion = sync.OnceValue(func() string {
|
||||||
|
var doc struct {
|
||||||
|
Info struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
} `json:"info"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(SpecJSON(), &doc)
|
||||||
|
return doc.Info.Version
|
||||||
|
})
|
||||||
|
|
||||||
|
func SpecVersion() string {
|
||||||
|
return specVersion()
|
||||||
|
}
|
||||||
37
api/embed_test.go
Normal file
37
api/embed_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/getkin/kin-openapi/openapi3"
|
||||||
|
"github.com/navidrome/navidrome/api"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Bundled spec", func() {
|
||||||
|
It("embeds a valid OpenAPI 3 document", func() {
|
||||||
|
doc, err := openapi3.NewLoader().LoadFromData(api.SpecJSON())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(doc.Validate(GinkgoT().Context())).To(Succeed())
|
||||||
|
Expect(doc.Paths.Find("/server")).ToNot(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("embeds the YAML variant", func() {
|
||||||
|
var doc map[string]any
|
||||||
|
Expect(yaml.Unmarshal(api.SpecYAML(), &doc)).To(Succeed())
|
||||||
|
Expect(doc).To(HaveKey("paths"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports the version from the bundle, matching the source root document", func() {
|
||||||
|
src, err := os.ReadFile("api/openapi/openapi.yaml")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
var root struct {
|
||||||
|
Info struct{ Version string } `yaml:"info"`
|
||||||
|
}
|
||||||
|
Expect(yaml.Unmarshal(src, &root)).To(Succeed())
|
||||||
|
Expect(api.SpecVersion()).To(Equal(root.Info.Version))
|
||||||
|
Expect(api.SpecVersion()).ToNot(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
3
api/openapi/components/headers/ETag.yaml
Normal file
3
api/openapi/components/headers/ETag.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
description: Entity tag for `If-None-Match` revalidation.
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
9
api/openapi/components/parameters/limit.yaml
Normal file
9
api/openapi/components/parameters/limit.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
name: limit
|
||||||
|
in: query
|
||||||
|
description: Maximum number of items to return.
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 2000
|
||||||
|
default: 100
|
||||||
8
api/openapi/components/parameters/offset.yaml
Normal file
8
api/openapi/components/parameters/offset.yaml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
name: offset
|
||||||
|
in: query
|
||||||
|
description: Zero-based index of the first item to return.
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
default: 0
|
||||||
5
api/openapi/components/responses/BadRequest.yaml
Normal file
5
api/openapi/components/responses/BadRequest.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: The request is malformed or fails validation.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
5
api/openapi/components/responses/Forbidden.yaml
Normal file
5
api/openapi/components/responses/Forbidden.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: The caller is authenticated but not allowed to do this.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
5
api/openapi/components/responses/InternalError.yaml
Normal file
5
api/openapi/components/responses/InternalError.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: Unexpected server failure. Details are in the server log.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
5
api/openapi/components/responses/NotFound.yaml
Normal file
5
api/openapi/components/responses/NotFound.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: No such resource or endpoint.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
4
api/openapi/components/responses/NotModified.yaml
Normal file
4
api/openapi/components/responses/NotModified.yaml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
description: Not modified.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: ../headers/ETag.yaml
|
||||||
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
5
api/openapi/components/responses/Unauthorized.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
description: Missing, invalid, or expired credentials.
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: ../schemas/Problem.yaml
|
||||||
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
13
api/openapi/components/schemas/ListMeta.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
type: object
|
||||||
|
description: Pagination metadata carried by every list response.
|
||||||
|
required: [total, offset, limit]
|
||||||
|
properties:
|
||||||
|
total:
|
||||||
|
type: integer
|
||||||
|
description: Total number of items matching the request, ignoring pagination.
|
||||||
|
offset:
|
||||||
|
type: integer
|
||||||
|
description: Zero-based index of the first returned item.
|
||||||
|
limit:
|
||||||
|
type: integer
|
||||||
|
description: Maximum number of items in this page.
|
||||||
35
api/openapi/components/schemas/Problem.yaml
Normal file
35
api/openapi/components/schemas/Problem.yaml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
type: object
|
||||||
|
description: RFC 9457 problem details, returned for every 4xx and 5xx response.
|
||||||
|
required: [title, status, code]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
URI reference identifying the problem type. Omitted while the problem carries no semantics
|
||||||
|
beyond its HTTP status code, which RFC 9457 defines as `about:blank`. Problems with their
|
||||||
|
own semantics get their own URI; switch on `code` instead.
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
description: Short human-readable summary, the same for all occurrences of this problem type.
|
||||||
|
status:
|
||||||
|
type: integer
|
||||||
|
description: HTTP status code of this response.
|
||||||
|
detail:
|
||||||
|
type: string
|
||||||
|
description: Human-readable explanation specific to this occurrence. Omitted for internal errors.
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
description: Machine-readable error code, and the value clients switch on. New codes may be added.
|
||||||
|
enum:
|
||||||
|
- validation
|
||||||
|
- unauthorized
|
||||||
|
- forbidden
|
||||||
|
- not_found
|
||||||
|
- method_not_allowed
|
||||||
|
- unavailable
|
||||||
|
- internal
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
description: Per-field failures. Present only when `code` is `validation`.
|
||||||
|
items:
|
||||||
|
$ref: ./ValidationError.yaml
|
||||||
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
22
api/openapi/components/schemas/ServerInfo.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
type: object
|
||||||
|
description: Public server description. Everything an add-server screen needs before login.
|
||||||
|
required: [name, serverVersion, specVersion, setupRequired, loginMethods]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Human-readable server product name.
|
||||||
|
serverVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the running server build.
|
||||||
|
specVersion:
|
||||||
|
type: string
|
||||||
|
description: Version of the OpenAPI document this server implements.
|
||||||
|
setupRequired:
|
||||||
|
type: boolean
|
||||||
|
description: True until the first admin user has been created.
|
||||||
|
loginMethods:
|
||||||
|
type: array
|
||||||
|
description: Login methods this server accepts. New methods may be added; clients ignore values they do not recognise.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
enum: [password]
|
||||||
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
10
api/openapi/components/schemas/ValidationError.yaml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
type: object
|
||||||
|
description: One field-level validation failure.
|
||||||
|
required: [field, message]
|
||||||
|
properties:
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
description: Name of the offending query parameter, path parameter, or body field (dotted for nested).
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: Why the value was rejected.
|
||||||
39
api/openapi/openapi.yaml
Normal file
39
api/openapi/openapi.yaml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Navidrome API
|
||||||
|
version: 1.0.0
|
||||||
|
description: |
|
||||||
|
Navidrome API v1. Spec-first, additive within v1. Clients discover implemented
|
||||||
|
capability modules through `GET /server` and never sniff versions.
|
||||||
|
|
||||||
|
Enums are open: new values may be added to any enum within v1. Clients must
|
||||||
|
accept values they do not recognise instead of failing.
|
||||||
|
|
||||||
|
Every operation declares `x-stability-level`: `alpha` operations may change or
|
||||||
|
disappear without notice, `beta` and `stable` operations only change additively.
|
||||||
|
A level is only ever raised, never lowered.
|
||||||
|
|
||||||
|
`HEAD` is accepted wherever `GET` is. A `405` response lists the allowed methods
|
||||||
|
in its `Allow` header.
|
||||||
|
license:
|
||||||
|
name: GPL-3.0
|
||||||
|
url: https://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
servers:
|
||||||
|
- url: /api/v1
|
||||||
|
tags:
|
||||||
|
- name: server
|
||||||
|
description: Server discovery and the published OpenAPI document.
|
||||||
|
paths:
|
||||||
|
/server:
|
||||||
|
$ref: ./paths/server.yaml
|
||||||
|
/openapi.json:
|
||||||
|
$ref: ./paths/openapi.yaml#/json
|
||||||
|
/openapi.yaml:
|
||||||
|
$ref: ./paths/openapi.yaml#/yaml
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
bearerAuth:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
bearerFormat: JWT
|
||||||
|
description: Short-lived access token minted from a device grant. Not yet applied to any operation.
|
||||||
42
api/openapi/paths/openapi.yaml
Normal file
42
api/openapi/paths/openapi.yaml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
json:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecJSON
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (JSON)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: ../components/headers/ETag.yaml
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: ../components/responses/NotModified.yaml
|
||||||
|
yaml:
|
||||||
|
get:
|
||||||
|
operationId: getOpenAPISpecYAML
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Get the OpenAPI document (YAML)
|
||||||
|
description: The bundled OpenAPI document of the running server version. Supports ETag revalidation.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The OpenAPI document.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
$ref: ../components/headers/ETag.yaml
|
||||||
|
content:
|
||||||
|
application/yaml:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: OpenAPI 3.0 document.
|
||||||
|
'304':
|
||||||
|
$ref: ../components/responses/NotModified.yaml
|
||||||
19
api/openapi/paths/server.yaml
Normal file
19
api/openapi/paths/server.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
get:
|
||||||
|
operationId: getServerInfo
|
||||||
|
x-module: core
|
||||||
|
x-stability-level: alpha
|
||||||
|
tags: [server]
|
||||||
|
summary: Describe the server
|
||||||
|
description: |
|
||||||
|
Returns the public server description. No authentication required.
|
||||||
|
Authenticated requests will additionally receive the implemented capability modules
|
||||||
|
once authentication is available.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Server description.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: ../components/schemas/ServerInfo.yaml
|
||||||
|
'500':
|
||||||
|
$ref: ../components/responses/InternalError.yaml
|
||||||
|
|
@ -174,28 +174,28 @@ func queueTotal(stats []model.ArtworkQueueStat) int64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error) {
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
var rep statusReport
|
var rep statusReport
|
||||||
var err error
|
var err error
|
||||||
if rep.queue, err = q.CountQueued(nil, nil); err != nil {
|
if rep.queue, err = q.CountQueued(ctx, nil, nil); err != nil {
|
||||||
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
return rep, fmt.Errorf("breaking the artwork queue down by kind: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
sources, err := q.SourcesInUse(k)
|
sources, err := q.SourcesInUse(ctx, k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return rep, fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
slices.Sort(sources)
|
slices.Sort(sources)
|
||||||
for _, s := range sources {
|
for _, s := range sources {
|
||||||
n, err := q.CountBySource(k, []string{s})
|
n, err := q.CountBySource(ctx, k, []string{s})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
return rep, fmt.Errorf("counting %s artwork resolved from %s: %w", k, displaySource(s), err)
|
||||||
}
|
}
|
||||||
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
rep.sources = append(rep.sources, sourceCount{kind: k, source: s, count: n})
|
||||||
// An absent state is exactly a row with no source, so it needs no second query.
|
// An absent state is exactly a row with no source, so it needs no second query.
|
||||||
if s == "" {
|
if s == "" {
|
||||||
failed, err := q.CountBySource(k, []string{model.ArtworkSourceFailed})
|
failed, err := q.CountBySource(ctx, k, []string{model.ArtworkSourceFailed})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
return rep, fmt.Errorf("counting failed %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -205,7 +205,7 @@ func collectStatus(ctx context.Context, ds model.DataStore) (statusReport, error
|
||||||
}
|
}
|
||||||
|
|
||||||
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
rep.current, rep.inputs = artwork.ConfigFingerprint(), artwork.FingerprintInputs()
|
||||||
if rep.stored, err = ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
if rep.stored, err = ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, ""); err != nil {
|
||||||
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
return rep, fmt.Errorf("reading the stored artwork fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
return rep, nil
|
return rep, nil
|
||||||
|
|
@ -442,13 +442,13 @@ func promptConfirm(in io.Reader, verb string) confirmFunc {
|
||||||
|
|
||||||
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
// validateSources rejects a typo'd source: matching nothing silently reads as "nothing to do" when
|
||||||
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
// it means the filter was wrong. Checked table-wide, so a filter is never a typo for one --kind only.
|
||||||
func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
func validateSources(ctx context.Context, q model.ArtworkQueueRepository, sources []string) error {
|
||||||
if len(sources) == 0 {
|
if len(sources) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var inUse []string
|
var inUse []string
|
||||||
for _, k := range artwork.ReprocessKinds {
|
for _, k := range artwork.ReprocessKinds {
|
||||||
found, err := q.SourcesInUse(k)
|
found, err := q.SourcesInUse(ctx, k)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
return fmt.Errorf("listing the sources in use by %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -475,8 +475,8 @@ func validateSources(q model.ArtworkQueueRepository, sources []string) error {
|
||||||
// actually inserted; the two differ because an already-queued row is left untouched.
|
// actually inserted; the two differ because an already-queued row is left untouched.
|
||||||
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, sources []string,
|
||||||
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
imageAgents artwork.ImageAgentCount, dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
if err := validateSources(q, sources); err != nil {
|
if err := validateSources(ctx, q, sources); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -495,7 +495,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
matched := make([]int64, len(kinds))
|
matched := make([]int64, len(kinds))
|
||||||
var total, external int64
|
var total, external int64
|
||||||
for i, k := range kinds {
|
for i, k := range kinds {
|
||||||
n, err := q.CountBySource(k, sources)
|
n, err := q.CountBySource(ctx, k, sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting %s artwork: %w", k, err)
|
return fmt.Errorf("counting %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -523,7 +523,7 @@ func reprocessArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kin
|
||||||
if matched[i] == 0 {
|
if matched[i] == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
n, err := q.EnqueueBySource(k, sources, model.ArtworkPriorityRecheck)
|
n, err := q.EnqueueBySource(ctx, k, sources, model.ArtworkPriorityRecheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
return fmt.Errorf("queueing %s artwork: %w", k, err)
|
||||||
}
|
}
|
||||||
|
|
@ -590,8 +590,8 @@ func parseAll[T comparable](values []string, parse func(string) (T, error)) ([]T
|
||||||
|
|
||||||
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind, priorities []int,
|
||||||
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
dryRun bool, confirm confirmFunc, out io.Writer) error {
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
matched, err := q.CountQueued(kinds, priorities)
|
matched, err := q.CountQueued(ctx, kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("counting queued artwork: %w", err)
|
return fmt.Errorf("counting queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +612,7 @@ func cancelArtwork(ctx context.Context, ds model.DataStore, kinds []model.Kind,
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelled, err := q.PurgeQueued(kinds, priorities)
|
cancelled, err := q.PurgeQueued(ctx, kinds, priorities)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("cancelling queued artwork: %w", err)
|
return fmt.Errorf("cancelling queued artwork: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -984,11 +984,11 @@ func runExplain(ctx context.Context, args []string) {
|
||||||
}
|
}
|
||||||
rep := explainReport{kind: kind, id: id, name: name}
|
rep := explainReport{kind: kind, id: id, name: name}
|
||||||
if artwork.KeepsState(kind) {
|
if artwork.KeepsState(kind) {
|
||||||
rep.stored, err = ds.Artwork(ctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
rep.stored, err = ds.Artwork().GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read artwork state", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
rep.queued, err = ds.ArtworkQueue(ctx).Get(kind, id, model.ImageTypePrimary)
|
rep.queued, err = ds.ArtworkQueue().Get(ctx, kind, id, model.ImageTypePrimary)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
log.Fatal(ctx, "Failed to read the artwork queue", "kind", kind, "id", id, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -491,17 +491,17 @@ var _ = Describe("explain/reprocess source round trip", func() {
|
||||||
|
|
||||||
It("names the absent state as reprocess --source accepts it", func() {
|
It("names the absent state as reprocess --source accepts it", func() {
|
||||||
ds := &tests.MockDataStore{}
|
ds := &tests.MockDataStore{}
|
||||||
art := ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
art := ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindArtistArtwork.Prefix(),
|
||||||
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
ItemID: "ar-1", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
|
|
||||||
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
shown := storedSource(formatExplain(explainReport{kind: model.KindArtistArtwork, id: "ar-1",
|
||||||
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
stored: &model.ItemArtwork{AttemptedAt: time.Now()}}))
|
||||||
|
|
||||||
q := ds.ArtworkQueue(ctx)
|
q := ds.ArtworkQueue()
|
||||||
Expect(validateSources(q, repositorySources([]string{shown}))).To(Succeed(),
|
Expect(validateSources(ctx, q, repositorySources([]string{shown}))).To(Succeed(),
|
||||||
"explain's spelling of a source must be pasteable into --source")
|
"explain's spelling of a source must be pasteable into --source")
|
||||||
Expect(validateSources(q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
Expect(validateSources(ctx, q, repositorySources([]string{"(" + shown + ")"}))).ToNot(Succeed(),
|
||||||
"a parenthesised name would be rejected, so explain must not print one")
|
"a parenthesised name would be rejected, so explain must not print one")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -573,7 +573,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
decline := func(io.Writer, int64, int64) bool { return false }
|
decline := func(io.Writer, int64, int64) bool { return false }
|
||||||
|
|
||||||
put := func(kind model.Kind, id, source string) {
|
put := func(kind model.Kind, id, source string) {
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Hash: "h" + id, Source: source})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -584,8 +584,8 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
conf.Server.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(ctx).(*tests.MockArtworkRepo)
|
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
put(model.KindArtistArtwork, "ar-1", "external:deezer")
|
||||||
put(model.KindArtistArtwork, "ar-2", "")
|
put(model.KindArtistArtwork, "ar-2", "")
|
||||||
|
|
@ -601,19 +601,19 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("album"))
|
Expect(out.String()).To(ContainSubstring("album"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("queues nothing when the operator declines", func() {
|
It("queues nothing when the operator declines", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, nil, imageAgents, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
DescribeTable("records the applied config only for a run that leaves nothing on the old one",
|
||||||
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
func(selected []model.Kind, sources []string, dryRun, applied bool) {
|
||||||
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, selected, sources, imageAgents, dryRun, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -621,7 +621,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
if applied {
|
if applied {
|
||||||
want = artwork.ConfigFingerprint()
|
want = artwork.ConfigFingerprint()
|
||||||
}
|
}
|
||||||
Expect(ds.Property(ctx).Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
Expect(ds.Property().Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(want))
|
||||||
},
|
},
|
||||||
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
Entry("every kind, unfiltered", artwork.ReprocessKinds, nil, false, true),
|
||||||
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
Entry("every kind, but nothing matched", artwork.ReprocessKinds, []string{}, false, true),
|
||||||
|
|
@ -633,14 +633,14 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
It("queues the matching items at recheck priority, leaving their artwork state alone", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count()).To(Equal(int64(2)))
|
Expect(queue.Count(ctx)).To(Equal(int64(2)))
|
||||||
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityRecheck))
|
||||||
_, err = queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err = queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
Expect(err).To(MatchError(model.ErrNotFound), "a non-matching source must not be queued")
|
||||||
|
|
||||||
stored, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
stored, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
Expect(stored.Hash).To(Equal("hal-1"), "bulk reprocessing must not blank the current artwork")
|
||||||
})
|
})
|
||||||
|
|
@ -648,20 +648,20 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
It("targets the absent state", func() {
|
It("targets the absent state", func() {
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{""}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count()).To(Equal(int64(1)))
|
Expect(queue.Count(ctx)).To(Equal(int64(1)))
|
||||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports matched and queued separately when part of the set is already queued", func() {
|
It("reports matched and queued separately when part of the set is already queued", func() {
|
||||||
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump})).To(Succeed())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
Expect(reprocessArtwork(ctx, ds, kinds, []string{"external:deezer"}, imageAgents, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
Expect(out.String()).To(ContainSubstring("Queued 1 of 2 matched items"))
|
||||||
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
Expect(out.String()).To(ContainSubstring("Already queued, left unchanged: 1"))
|
||||||
queued, err := queue.Get(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
queued, err := queue.Get(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump),
|
||||||
"an already-queued row keeps its priority and backoff")
|
"an already-queued row keeps its priority and backoff")
|
||||||
|
|
@ -675,7 +675,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
}, &out)).To(Succeed())
|
}, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing"))
|
Expect(out.String()).To(ContainSubstring("Nothing"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports an empty selection as a dry run when one was asked for", func() {
|
It("reports an empty selection as a dry run when one was asked for", func() {
|
||||||
|
|
@ -767,7 +767,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
Expect(err.Error()).To(ContainSubstring("external:deezer"))
|
||||||
Expect(err.Error()).To(ContainSubstring("folder"))
|
Expect(err.Error()).To(ContainSubstring("folder"))
|
||||||
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
Expect(err.Error()).To(ContainSubstring("absent"), "the empty source prints under its user-facing name")
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
It("accepts the absent filter with nothing absent, still rejecting a typo", func() {
|
||||||
|
|
@ -777,7 +777,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
imageAgents, false, accept, &out)).To(Succeed(),
|
imageAgents, false, accept, &out)).To(Succeed(),
|
||||||
"a reserved source must stay valid once the library has none of it")
|
"a reserved source must stay valid once the library has none of it")
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
Expect(out.String()).To(ContainSubstring("Nothing matches"))
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
|
|
||||||
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
Expect(reprocessArtwork(ctx, ds, kinds, repositorySources([]string{"absnt"}),
|
||||||
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
imageAgents, true, accept, &out)).ToNot(Succeed(), "a typo must still be rejected")
|
||||||
|
|
@ -797,7 +797,7 @@ var _ = Describe("reprocessArtwork", func() {
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
Expect(out.String()).To(ContainSubstring("Nothing matches"),
|
||||||
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
"a well-formed filter must not be reported as a typo because of the kinds selected")
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -816,19 +816,19 @@ var _ = Describe("collectStatus", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
put := func(kind model.Kind, id, source, hash string, attempted time.Time) {
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind.Prefix(), ItemID: id,
|
||||||
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Source: source, Hash: hash, AttemptedAt: attempted})).To(Succeed())
|
||||||
}
|
}
|
||||||
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
put(model.KindArtistArtwork, "ar-1", "external:deezer", "h1", time.Now())
|
||||||
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
put(model.KindArtistArtwork, "ar-2", "", "", time.Now().Add(-24*time.Hour))
|
||||||
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
// ar-3 is absent because it gave up, so the two absent artists split across the columns.
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "ar-3",
|
||||||
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
ImageType: model.ImageTypePrimary, LastFailure: "[]", AttemptedAt: time.Now()})).To(Succeed())
|
||||||
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
put(model.KindAlbumArtwork, "al-1", "folder", "h2", time.Now())
|
||||||
Expect(queue.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
Expect(queue.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-9",
|
||||||
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBackfill})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -848,7 +848,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("compares the stored fingerprint against the current one", func() {
|
It("compares the stored fingerprint against the current one", func() {
|
||||||
Expect(ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
Expect(ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, "old-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
rep, err := collectStatus(ctx, ds)
|
rep, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -860,7 +860,7 @@ var _ = Describe("collectStatus", func() {
|
||||||
It("queues nothing", func() {
|
It("queues nothing", func() {
|
||||||
_, err := collectStatus(ctx, ds)
|
_, err := collectStatus(ctx, ds)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queue.Count()).To(Equal(int64(1)), "status must not enqueue anything")
|
Expect(queue.Count(ctx)).To(Equal(int64(1)), "status must not enqueue anything")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -973,21 +973,21 @@ var _ = Describe("refreshItems", func() {
|
||||||
albums := tests.CreateMockAlbumRepo()
|
albums := tests.CreateMockAlbumRepo()
|
||||||
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
albums.SetData(model.Albums{{ID: "al-1"}, {ID: "al-3"}})
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albums}
|
ds = &tests.MockDataStore{MockedAlbum: albums}
|
||||||
art = ds.Artwork(ctx).(*tests.MockArtworkRepo)
|
art = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clears the stored state and queues each id at Bump priority", func() {
|
It("clears the stored state and queues each id at Bump priority", func() {
|
||||||
Expect(art.PutItemArtwork(&model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
Expect(art.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: model.KindAlbumArtwork.Prefix(),
|
||||||
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
ItemID: "al-1", ImageType: model.ImageTypePrimary, Hash: "abc123"})).To(Succeed())
|
||||||
|
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{
|
||||||
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
{Kind: model.KindAlbumArtwork, ID: "al-1"}, {Kind: model.KindAlbumArtwork, ID: "al-3"}}, &out)).To(BeZero())
|
||||||
|
|
||||||
_, err := art.GetItemArtwork(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
_, err := art.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
queued, err := queue.Get(model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
queued, err := queue.Get(ctx, model.KindAlbumArtwork, "al-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queued.Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
Expect(out.String()).To(Equal("al/al-1: queued\nal/al-3: queued\n"))
|
||||||
|
|
@ -996,7 +996,7 @@ var _ = Describe("refreshItems", func() {
|
||||||
It("skips an id that does not exist instead of queuing it", func() {
|
It("skips an id that does not exist instead of queuing it", func() {
|
||||||
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
Expect(refreshItems(ctx, ds, []model.ArtworkID{{Kind: model.KindAlbumArtwork, ID: "al-2"}}, &out)).To(Equal(1))
|
||||||
|
|
||||||
_, err := queue.Get(model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
_, err := queue.Get(ctx, model.KindAlbumArtwork, "al-2", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
Expect(err).To(MatchError(model.ErrNotFound), "a typo must not leave an orphan queue row")
|
||||||
Expect(out.String()).To(BeEmpty())
|
Expect(out.String()).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -1145,9 +1145,9 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
queue = ds.ArtworkQueue(ctx).(*tests.MockArtworkQueueRepo)
|
queue = ds.ArtworkQueue().(*tests.MockArtworkQueueRepo)
|
||||||
out.Reset()
|
out.Reset()
|
||||||
Expect(queue.Enqueue(
|
Expect(queue.Enqueue(ctx,
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-1", ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBackfill},
|
Priority: model.ArtworkPriorityBackfill},
|
||||||
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar-2", ImageType: model.ImageTypePrimary,
|
||||||
|
|
@ -1164,28 +1164,28 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(out.String()).To(ContainSubstring("backfill"))
|
Expect(out.String()).To(ContainSubstring("backfill"))
|
||||||
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
Expect(out.String()).To(ContainSubstring("TOTAL"))
|
||||||
Expect(out.String()).To(ContainSubstring("Dry run"))
|
Expect(out.String()).To(ContainSubstring("Dry run"))
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels nothing when the operator declines", func() {
|
It("cancels nothing when the operator declines", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, decline, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Aborted"))
|
Expect(out.String()).To(ContainSubstring("Aborted"))
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("deletes the selected rows and leaves the rest queued", func() {
|
It("deletes the selected rows and leaves the rest queued", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, []int{model.ArtworkPriorityBackfill}, false, accept, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 1))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 1))
|
||||||
_, err := queue.Get(model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
_, err := queue.Get(ctx, model.KindArtistArtwork, "ar-2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
Expect(err).ToNot(HaveOccurred(), "a non-matching priority must stay queued")
|
||||||
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
Expect(out.String()).To(ContainSubstring("Cancelled 2 of 2 matched items."))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("cancels every kind and priority when neither filter is given", func() {
|
It("cancels every kind and priority when neither filter is given", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, nil, nil, false, accept, &out)).To(Succeed())
|
||||||
Expect(queue.Count()).To(BeZero())
|
Expect(queue.Count(ctx)).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("stops at a selection that matches nothing instead of prompting", func() {
|
It("stops at a selection that matches nothing instead of prompting", func() {
|
||||||
|
|
@ -1196,7 +1196,7 @@ var _ = Describe("cancelArtwork", func() {
|
||||||
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
Expect(cancelArtwork(ctx, ds, []model.Kind{model.KindPlaylistArtwork}, nil, false, refuse, &out)).To(Succeed())
|
||||||
|
|
||||||
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
Expect(out.String()).To(ContainSubstring("Nothing matches this selection."))
|
||||||
Expect(queue.Count()).To(BeNumerically("==", 3))
|
Expect(queue.Count(ctx)).To(BeNumerically("==", 3))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
It("reports a queue read failure instead of reporting nothing to cancel", func() {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ var (
|
||||||
backupCount int
|
backupCount int
|
||||||
backupDir string
|
backupDir string
|
||||||
force bool
|
force bool
|
||||||
|
integrityCheck bool
|
||||||
restorePath string
|
restorePath string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -31,6 +32,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +138,7 @@ func runRestore(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := db.Restore(ctx, restorePath)
|
err := db.Restore(ctx, restorePath, integrityCheck)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
log.Fatal("Error restoring database", "backupFile", restorePath, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
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"
|
||||||
|
|
@ -28,7 +32,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(args)
|
runInspector(cmd.Context(), args)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,18 +59,24 @@ func prettyMarshal(v any) ([]byte, error) {
|
||||||
return []byte(res.String()), nil
|
return []byte(res.String()), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspector(args []string) {
|
func runInspector(ctx context.Context, 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
|
||||||
}
|
}
|
||||||
output, err := core.Inspect(filePath, 1, "")
|
lib, ok := libraryForFile(matcher, filePath)
|
||||||
|
if !ok && len(libs) > 0 {
|
||||||
|
log.Warn("File is not in any library, using the global PID config", "file", filePath)
|
||||||
|
}
|
||||||
|
output, err := core.Inspect(filePath, lib, "")
|
||||||
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
|
||||||
|
|
@ -77,3 +87,33 @@ func runInspector(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
|
||||||
|
}
|
||||||
|
|
|
||||||
61
cmd/inspect_test.go
Normal file
61
cmd/inspect_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("inspect", func() {
|
||||||
|
Describe("libraryForFile", func() {
|
||||||
|
var matcher *model.LibraryMatcher
|
||||||
|
var root string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
root = GinkgoT().TempDir()
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
matcher = model.NewLibraryMatcher(model.Libraries{
|
||||||
|
{ID: 1, Path: filepath.Join(root, "music")},
|
||||||
|
{ID: 2, Path: filepath.Join(cwd, "loose"), PIDAlbum: "folder"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the library that contains an absolute path", func() {
|
||||||
|
lib, ok := libraryForFile(matcher, filepath.Join(root, "music", "album", "track.mp3"))
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(lib.ID).To(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("resolves a relative path against the working directory", func() {
|
||||||
|
lib, ok := libraryForFile(matcher, filepath.Join("loose", "track.mp3"))
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(lib.PIDAlbum).To(Equal("folder"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to the default library without overrides", func() {
|
||||||
|
lib, ok := libraryForFile(matcher, filepath.Join(root, "elsewhere", "track.mp3"))
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
Expect(lib).To(Equal(model.Library{ID: model.DefaultLibraryID}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("loadLibraries", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not create a database when there is none", func() {
|
||||||
|
dbFile := filepath.Join(GinkgoT().TempDir(), "navidrome.db")
|
||||||
|
conf.Server.DbPath = dbFile + "?_journal_mode=WAL"
|
||||||
|
|
||||||
|
Expect(loadLibraries(GinkgoT().Context())).To(BeNil())
|
||||||
|
Expect(dbFile).ToNot(BeAnExistingFile())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -72,7 +72,7 @@ func runMissingList(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
mfs, err := ds.MediaFile(ctx).GetCursor(model.QueryOptions{
|
mfs, err := ds.MediaFile().GetCursor(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"missing": true},
|
Filters: squirrel.Eq{"missing": true},
|
||||||
Sort: "path",
|
Sort: "path",
|
||||||
})
|
})
|
||||||
|
|
@ -128,7 +128,7 @@ func runMissingFix(ctx context.Context, missingRef, targetRef string) {
|
||||||
|
|
||||||
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
// resolveMediaFile looks up a media file by ID first, then by path (optionally libraryID:path).
|
||||||
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *model.MediaFile {
|
||||||
mf, err := ds.MediaFile(ctx).Get(ref)
|
mf, err := ds.MediaFile().Get(ctx, ref)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return mf
|
return mf
|
||||||
}
|
}
|
||||||
|
|
@ -136,7 +136,7 @@ func resolveMediaFile(ctx context.Context, ds model.DataStore, ref string) *mode
|
||||||
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
log.Fatal(ctx, "Error looking up media file", "ref", ref, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
mfs, err := ds.MediaFile(ctx).FindByPaths([]string{ref})
|
mfs, err := ds.MediaFile().FindByPaths(ctx, []string{ref})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
log.Fatal(ctx, "Error looking up media file by path", "ref", ref, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
cmd/pls.go
10
cmd/pls.go
|
|
@ -109,7 +109,7 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
options.Filters = squirrel.Eq{"owner_id": user.ID}
|
||||||
}
|
}
|
||||||
pls, err := ds.Playlist(ctx).GetAll(options)
|
pls, err := ds.Playlist().GetAll(ctx, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
log.Fatal(ctx, "Failed to retrieve playlists", err)
|
||||||
}
|
}
|
||||||
|
|
@ -117,17 +117,17 @@ func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.
|
||||||
}
|
}
|
||||||
|
|
||||||
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist {
|
||||||
playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false)
|
playlist, err := ds.Playlist().GetWithTracks(ctx, nameOrID, true, false)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
playlists, err := ds.Playlist().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
if len(playlists) > 0 {
|
if len(playlists) > 0 {
|
||||||
playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false)
|
playlist, err = ds.Playlist().GetWithTracks(ctx, playlists[0].ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
log.Fatal("Error retrieving playlist", "name", nameOrID, err)
|
||||||
}
|
}
|
||||||
|
|
@ -194,7 +194,7 @@ func runExport(ctx context.Context) {
|
||||||
|
|
||||||
exported := 0
|
exported := 0
|
||||||
for _, pls := range allPls {
|
for _, pls := range allPls {
|
||||||
plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false)
|
plsWithTracks, err := ds.Playlist().GetWithTracks(ctx, pls.ID, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
log.Error("Error loading playlist tracks", "playlist", pls.Name, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -243,7 +243,7 @@ func runPluginInfo(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin(ctx).Get(arg)
|
p, err := ds.Plugin().Get(ctx, arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -264,7 +264,7 @@ func runPluginValidate(ctx context.Context, arg string) {
|
||||||
}
|
}
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
p, err := ds.Plugin(ctx).Get(arg)
|
p, err := ds.Plugin().Get(ctx, arg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
log.Fatal(ctx, "Plugin not found", "id", arg, err)
|
||||||
}
|
}
|
||||||
|
|
@ -329,7 +329,7 @@ func formatPluginList(list model.Plugins, format string) (string, error) {
|
||||||
func runPluginList(ctx context.Context) {
|
func runPluginList(ctx context.Context) {
|
||||||
requirePluginsEnabled(ctx)
|
requirePluginsEnabled(ctx)
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
list, err := ds.Plugin(ctx).GetAll()
|
list, err := ds.Plugin().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to list plugins", err)
|
log.Fatal(ctx, "Failed to list plugins", err)
|
||||||
}
|
}
|
||||||
|
|
@ -372,7 +372,7 @@ var pluginEditCmd = &cobra.Command{
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
requirePluginsEnabled(cmd.Context())
|
requirePluginsEnabled(cmd.Context())
|
||||||
ds, ctx := getAdminContext(cmd.Context())
|
ds, ctx := getAdminContext(cmd.Context())
|
||||||
cur, err := ds.Plugin(ctx).Get(args[0])
|
cur, err := ds.Plugin().Get(ctx, args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
log.Fatal(ctx, "Plugin not found", "id", args[0], err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
cmd/root.go
57
cmd/root.go
|
|
@ -44,7 +44,9 @@ Complete documentation is available at https://www.navidrome.org/docs`,
|
||||||
preRun()
|
preRun()
|
||||||
},
|
},
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
runNavidrome(cmd.Context())
|
if err := runNavidrome(cmd.Context()); err != nil {
|
||||||
|
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
PostRun: func(cmd *cobra.Command, args []string) {
|
PostRun: func(cmd *cobra.Command, args []string) {
|
||||||
postRun()
|
postRun()
|
||||||
|
|
@ -76,12 +78,12 @@ func postRun() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
// runNavidrome is the main entry point for the Navidrome server. It starts all the services and blocks.
|
||||||
// If any of the services returns an error, it will log it and exit. If the process receives a signal to exit,
|
// If any of the services returns an error, it stops the others and returns that error, so the caller can
|
||||||
// it will cancel the context and exit gracefully.
|
// exit with a non-zero code. If the context is cancelled (a signal or a service stop), it returns nil.
|
||||||
func runNavidrome(ctx context.Context) {
|
func runNavidrome(parentCtx context.Context) error {
|
||||||
defer db.Init(ctx)()
|
defer db.Init(parentCtx)()
|
||||||
|
|
||||||
g, ctx := errgroup.WithContext(ctx)
|
g, ctx := errgroup.WithContext(parentCtx)
|
||||||
g.Go(startServer(ctx))
|
g.Go(startServer(ctx))
|
||||||
g.Go(startSignaller(ctx))
|
g.Go(startSignaller(ctx))
|
||||||
g.Go(startScheduler(ctx))
|
g.Go(startScheduler(ctx))
|
||||||
|
|
@ -102,9 +104,11 @@ func runNavidrome(ctx context.Context) {
|
||||||
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
log.Warn(ctx, "Automatic Scanning is DISABLED")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.Wait(); err != nil {
|
// Errors caused by a normal shutdown are not failures
|
||||||
log.Error("Fatal error in Navidrome. Aborting", err)
|
if err := g.Wait(); err != nil && parentCtx.Err() == nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
// mainContext returns a context that is cancelled when the process receives a signal to exit.
|
||||||
|
|
@ -133,6 +137,9 @@ func startServer(ctx context.Context) func() error {
|
||||||
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 {
|
||||||
|
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
|
||||||
|
|
@ -183,46 +190,50 @@ func schedulePeriodicScan(ctx context.Context) func() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pidHashChanged(ds model.DataStore) (bool, error) {
|
// librariesWithChangedPID returns the names of the libraries whose effective PID config differs from
|
||||||
pidAlbum, err := ds.Property(context.Background()).DefaultGet(consts.PIDAlbumKey, "")
|
// the one used by their last finished scan
|
||||||
|
func librariesWithChangedPID(ctx context.Context, ds model.DataStore) ([]string, error) {
|
||||||
|
libs, err := ds.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return nil, err
|
||||||
}
|
}
|
||||||
pidTrack, err := ds.Property(context.Background()).DefaultGet(consts.PIDTrackKey, "")
|
var names []string
|
||||||
if err != nil {
|
for _, lib := range libs {
|
||||||
return false, err
|
if lib.PIDChanged() {
|
||||||
|
names = append(names, lib.Name)
|
||||||
}
|
}
|
||||||
return !strings.EqualFold(pidAlbum, conf.Server.PID.Album) || !strings.EqualFold(pidTrack, conf.Server.PID.Track), nil
|
}
|
||||||
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runInitialScan runs an initial scan of the music library if needed.
|
// runInitialScan runs an initial scan of the music library if needed.
|
||||||
func runInitialScan(ctx context.Context) func() error {
|
func runInitialScan(ctx context.Context) func() error {
|
||||||
return func() error {
|
return func() error {
|
||||||
ds := CreateDataStore()
|
ds := CreateDataStore()
|
||||||
fullScanRequired, err := ds.Property(ctx).DefaultGet(consts.FullScanAfterMigrationFlagKey, "0")
|
fullScanRequired, err := ds.Property().DefaultGet(ctx, consts.FullScanAfterMigrationFlagKey, "0")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inProgress, err := ds.Library(ctx).ScanInProgress()
|
inProgress, err := ds.Library().ScanInProgress(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
pidHasChanged, err := pidHashChanged(ds)
|
pidChangedLibs, err := librariesWithChangedPID(ctx, 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" || pidHasChanged
|
scanNeeded := scanOnStartup || inProgress || fullScanRequired == "1" || len(pidChangedLibs) > 0
|
||||||
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
time.Sleep(2 * time.Second) // Wait 2 seconds before the initial scan
|
||||||
if scanNeeded {
|
if scanNeeded {
|
||||||
s := CreateScanner(ctx)
|
s := CreateScanner(ctx)
|
||||||
switch {
|
switch {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property(ctx).Delete(consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
||||||
case pidHasChanged:
|
case len(pidChangedLibs) > 0:
|
||||||
log.Warn(ctx, "PID config changed, performing full scan")
|
// Includes never-scanned libraries. The scanner rescans in full only the ones that need it
|
||||||
fullScanRequired = "1"
|
log.Warn(ctx, "Libraries with a new or changed PID config, scanning", "libraries", pidChangedLibs)
|
||||||
case inProgress:
|
case inProgress:
|
||||||
log.Warn(ctx, "Resuming interrupted scan")
|
log.Warn(ctx, "Resuming interrupted scan")
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path"
|
"path"
|
||||||
|
|
@ -9,6 +10,8 @@ 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/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -44,3 +47,30 @@ 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() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
libs = &tests.MockLibraryRepo{}
|
||||||
|
ds = &tests.MockDataStore{MockedLibrary: libs}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns only the libraries whose PID config changed", func() {
|
||||||
|
pid := model.Library{}.EffectivePID()
|
||||||
|
libs.SetData(model.Libraries{
|
||||||
|
{ID: 1, Name: "Same", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
||||||
|
{ID: 2, Name: "Changed", PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
||||||
|
{ID: 3, Name: "Never scanned"},
|
||||||
|
})
|
||||||
|
Expect(librariesWithChangedPID(GinkgoT().Context(), ds)).To(ConsistOf("Changed", "Never scanned"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns the error from the repository", func() {
|
||||||
|
libs.Err = errors.New("db down")
|
||||||
|
_, err := librariesWithChangedPID(GinkgoT().Context(), ds)
|
||||||
|
Expect(err).To(MatchError("db down"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
11
cmd/svc.go
11
cmd/svc.go
|
|
@ -44,7 +44,7 @@ var svcCmd = &cobra.Command{
|
||||||
}
|
}
|
||||||
|
|
||||||
type svcControl struct {
|
type svcControl struct {
|
||||||
ctx context.Context
|
ctx context.Context //nolint:containedctx // service lifecycle ctx, cancelled by Stop
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
@ -53,8 +53,13 @@ func (p *svcControl) Start(service.Service) error {
|
||||||
p.done = make(chan struct{})
|
p.done = make(chan struct{})
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
runNavidrome(p.ctx)
|
err := runNavidrome(p.ctx)
|
||||||
close(p.done)
|
close(p.done)
|
||||||
|
// service.Run() only returns when it gets a stop request, so exit here to let the
|
||||||
|
// service manager see the failure and restart the service
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Fatal error in Navidrome. Aborting", err)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -74,7 +79,7 @@ func (p *svcControl) Stop(service.Service) error {
|
||||||
var svcInstance = sync.OnceValue(func() service.Service {
|
var svcInstance = sync.OnceValue(func() service.Service {
|
||||||
options := make(service.KeyValue)
|
options := make(service.KeyValue)
|
||||||
options["Restart"] = "on-failure"
|
options["Restart"] = "on-failure"
|
||||||
options["SuccessExitStatus"] = "1 2 8 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
|
||||||
|
|
|
||||||
26
cmd/user.go
26
cmd/user.go
|
|
@ -183,7 +183,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
err := ds.WithTx(func(tx model.DataStore) error {
|
err := ds.WithTx(func(tx model.DataStore) error {
|
||||||
existingUser, err := tx.User(ctx).FindByUsername(userID)
|
existingUser, err := tx.User().FindByUsername(ctx, userID)
|
||||||
if existingUser != nil {
|
if existingUser != nil {
|
||||||
return fmt.Errorf("existing user '%s'", userID)
|
return fmt.Errorf("existing user '%s'", userID)
|
||||||
}
|
}
|
||||||
|
|
@ -193,7 +193,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
user.Libraries, err = tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -202,13 +202,13 @@ func runCreateUser(ctx context.Context) {
|
||||||
return libraryError(user.Libraries)
|
return libraryError(user.Libraries)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
user.Libraries, err = tx.Library(ctx).GetAll()
|
user.Libraries, err = tx.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User(ctx).Put(&user)
|
err = tx.User().Put(ctx, &user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ func runCreateUser(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
err = tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||||
return err
|
return err
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -236,7 +236,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
var user *model.User
|
var user *model.User
|
||||||
|
|
||||||
err = ds.WithTx(func(tx model.DataStore) error {
|
err = ds.WithTx(func(tx model.DataStore) error {
|
||||||
count, err := tx.User(ctx).CountAll()
|
count, err := tx.User().CountAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -250,7 +250,7 @@ func runDeleteUser(ctx context.Context) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return tx.User(ctx).Delete(user.ID)
|
return tx.User().Delete(ctx, user.ID)
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -276,7 +276,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(libraryIds) > 0 && !setAdmin {
|
if len(libraryIds) > 0 && !setAdmin {
|
||||||
libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
libraries, err := tx.Library().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -291,7 +291,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if setAdmin && !user.IsAdmin {
|
if setAdmin && !user.IsAdmin {
|
||||||
libraries, err := tx.Library(ctx).GetAll()
|
libraries, err := tx.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -337,7 +337,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User(ctx).Put(user)
|
err := tx.User().Put(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +348,7 @@ func runUserEdit(ctx context.Context) {
|
||||||
updatedIds[idx] = lib.ID
|
updatedIds[idx] = lib.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
|
err := tx.User().SetUserLibraries(ctx, user.ID, updatedIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -393,13 +393,11 @@ func runUserList(ctx context.Context) {
|
||||||
|
|
||||||
ds, ctx := getAdminContext(ctx)
|
ds, ctx := getAdminContext(ctx)
|
||||||
|
|
||||||
users, err := ds.User(ctx).ReadAll()
|
userList, err := ds.User().ReadAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(ctx, "Failed to retrieve users", err)
|
log.Fatal(ctx, "Failed to retrieve users", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
userList := users.(model.Users)
|
|
||||||
|
|
||||||
if outputFormat == "csv" {
|
if outputFormat == "csv" {
|
||||||
w := csv.NewWriter(os.Stdout)
|
w := csv.NewWriter(os.Stdout)
|
||||||
_ = w.Write([]string{
|
_ = w.Write([]string{
|
||||||
|
|
|
||||||
17
cmd/utils.go
17
cmd/utils.go
|
|
@ -18,11 +18,16 @@ import (
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
)
|
)
|
||||||
|
|
||||||
// requireExistingDB aborts the command when the database file (DbPath minus DSN
|
// existingDBFile returns the database file (DbPath minus DSN params), and whether it exists.
|
||||||
// params) does not exist.
|
func existingDBFile() (string, bool) {
|
||||||
func requireExistingDB() {
|
|
||||||
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
path, _, _ := strings.Cut(conf.Server.DbPath, "?")
|
||||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
_, err := os.Stat(path)
|
||||||
|
return path, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireExistingDB aborts the command when the database file does not exist.
|
||||||
|
func requireExistingDB() {
|
||||||
|
if path, ok := existingDBFile(); !ok {
|
||||||
log.Fatal("No existing database", "path", path)
|
log.Fatal("No existing database", "path", path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,14 +57,14 @@ func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
|
||||||
user, err := ds.User(ctx).FindByUsername(id)
|
user, err := ds.User().FindByUsername(ctx, id)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, fmt.Errorf("finding user by name: %w", err)
|
return nil, fmt.Errorf("finding user by name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
user, err = ds.User(ctx).Get(id)
|
user, err = ds.User().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("finding user by id: %w", err)
|
return nil, fmt.Errorf("finding user by id: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
|
"github.com/navidrome/navidrome/server/apiv1"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -94,8 +95,9 @@ 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, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, 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)
|
||||||
|
|
@ -109,7 +111,6 @@ 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
|
||||||
|
|
@ -142,6 +143,13 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||||
|
sqlDB := db.Db()
|
||||||
|
dataStore := persistence.New(sqlDB)
|
||||||
|
router := apiv1.New(dataStore)
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
sqlDB := db.Db()
|
sqlDB := db.Db()
|
||||||
dataStore := persistence.New(sqlDB)
|
dataStore := persistence.New(sqlDB)
|
||||||
|
|
@ -151,9 +159,10 @@ 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, dataStore, share)
|
archiver := core.NewArchiver(mediaStreamer, transcodeDecider, dataStore, share, artworkArtwork)
|
||||||
router := public.New(dataStore, artworkArtwork, mediaStreamer, share, archiver)
|
router := public.New(dataStore, artworkArtwork, mediaStreamer, transcodeDecider, share, archiver)
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -259,7 +268,7 @@ func getPluginManager() *plugins.Manager {
|
||||||
|
|
||||||
// wire_injectors.go:
|
// wire_injectors.go:
|
||||||
|
|
||||||
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, jellyfin.New, jellyfin.NewDiscovery, apiv1.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.GetInstance, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.Engine), new(*sonic.Sonic)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher)), wire.Bind(new(playlists.ImageUploadService), new(artwork.Uploader)))
|
||||||
|
|
||||||
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
func GetPluginManager(ctx context.Context) *plugins.Manager {
|
||||||
manager := getPluginManager()
|
manager := getPluginManager()
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/plugins"
|
"github.com/navidrome/navidrome/plugins"
|
||||||
"github.com/navidrome/navidrome/scanner"
|
"github.com/navidrome/navidrome/scanner"
|
||||||
"github.com/navidrome/navidrome/server"
|
"github.com/navidrome/navidrome/server"
|
||||||
|
"github.com/navidrome/navidrome/server/apiv1"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin"
|
"github.com/navidrome/navidrome/server/jellyfin"
|
||||||
"github.com/navidrome/navidrome/server/nativeapi"
|
"github.com/navidrome/navidrome/server/nativeapi"
|
||||||
|
|
@ -37,6 +38,7 @@ var allProviders = wire.NewSet(
|
||||||
subsonic.New,
|
subsonic.New,
|
||||||
jellyfin.New,
|
jellyfin.New,
|
||||||
jellyfin.NewDiscovery,
|
jellyfin.NewDiscovery,
|
||||||
|
apiv1.New,
|
||||||
nativeapi.New,
|
nativeapi.New,
|
||||||
public.New,
|
public.New,
|
||||||
persistence.New,
|
persistence.New,
|
||||||
|
|
@ -91,6 +93,12 @@ func CreateJellyfinAPIRouter(ctx context.Context) *jellyfin.Router {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateAPIv1Router(ctx context.Context) *apiv1.Router {
|
||||||
|
panic(wire.Build(
|
||||||
|
allProviders,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func CreatePublicRouter() *public.Router {
|
func CreatePublicRouter() *public.Router {
|
||||||
panic(wire.Build(
|
panic(wire.Build(
|
||||||
allProviders,
|
allProviders,
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,7 @@ type configOptions struct {
|
||||||
DevExternalArtistFetchMultiplier float64
|
DevExternalArtistFetchMultiplier float64
|
||||||
DevPreserveUnicodeInExternalCalls bool
|
DevPreserveUnicodeInExternalCalls bool
|
||||||
DevEnableMediaFileProbe bool
|
DevEnableMediaFileProbe bool
|
||||||
|
DevAPIv1 bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type scannerOptions struct {
|
type scannerOptions struct {
|
||||||
|
|
@ -514,6 +515,11 @@ func Load(noConfigDump bool) {
|
||||||
Server.UICoverArtSize = newValue
|
Server.UICoverArtSize = newValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if Server.Scanner.Extractor != consts.DefaultScannerExtractor {
|
||||||
|
log.Warn("Invalid Scanner.Extractor, using default", "value", Server.Scanner.Extractor, "default", consts.DefaultScannerExtractor)
|
||||||
|
Server.Scanner.Extractor = consts.DefaultScannerExtractor
|
||||||
|
}
|
||||||
|
|
||||||
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
// Floor MaxImageSize at MaxImageUploadSize so accepted uploads can always be read back.
|
||||||
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
imgSize, _ := humanize.ParseBytes(Server.MaxImageSize)
|
||||||
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
uploadSize, _ := humanize.ParseBytes(Server.MaxImageUploadSize)
|
||||||
|
|
@ -1119,6 +1125,7 @@ func setViperDefaults() {
|
||||||
viper.SetDefault("devshowartistpage", true)
|
viper.SetDefault("devshowartistpage", true)
|
||||||
viper.SetDefault("devuishowconfig", true)
|
viper.SetDefault("devuishowconfig", true)
|
||||||
viper.SetDefault("devneweventstream", true)
|
viper.SetDefault("devneweventstream", true)
|
||||||
|
viper.SetDefault("devapiv1", false)
|
||||||
viper.SetDefault("devoffsetoptimize", 50000)
|
viper.SetDefault("devoffsetoptimize", 50000)
|
||||||
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
// Half the pool: streams may take up to this many connections, leaving the rest for the scanner,
|
||||||
// scrobbles and the UI. See MaxOpenConns.
|
// scrobbles and the UI. See MaxOpenConns.
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,27 @@ var _ = Describe("Configuration", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Scanner.Extractor", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
viper.Reset()
|
||||||
|
conf.SetViperDefaults()
|
||||||
|
viper.SetDefault("datafolder", GinkgoT().TempDir())
|
||||||
|
viper.SetDefault("loglevel", "error")
|
||||||
|
conf.ResetConf()
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to taglib for an unknown extractor", func() {
|
||||||
|
viper.SetDefault("scanner.extractor", "ffmpeg")
|
||||||
|
conf.Load(true)
|
||||||
|
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps taglib", func() {
|
||||||
|
conf.Load(true)
|
||||||
|
Expect(conf.Server.Scanner.Extractor).To(Equal("taglib"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("EnforceNonRootUser", func() {
|
Describe("EnforceNonRootUser", func() {
|
||||||
It("defaults to false", func() {
|
It("defaults to false", func() {
|
||||||
conf.Load(true)
|
conf.Load(true)
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ const (
|
||||||
DefaultEncryptionKey = "just for obfuscation"
|
DefaultEncryptionKey = "just for obfuscation"
|
||||||
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
PasswordsEncryptedKey = "PasswordsEncryptedKey"
|
||||||
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
PasswordAutogenPrefix = "__NAVIDROME_AUTOGEN__" //nolint:gosec
|
||||||
|
APIKeyPrefix = "nds_"
|
||||||
|
|
||||||
DevInitialUserName = "admin"
|
DevInitialUserName = "admin"
|
||||||
DevInitialName = "Dev Admin"
|
DevInitialName = "Dev Admin"
|
||||||
|
|
@ -59,6 +60,7 @@ const (
|
||||||
URLPathPublic = "/share"
|
URLPathPublic = "/share"
|
||||||
URLPathPublicImages = URLPathPublic + "/img"
|
URLPathPublicImages = URLPathPublic + "/img"
|
||||||
URLPathJellyfinAPI = "/jellyfin"
|
URLPathJellyfinAPI = "/jellyfin"
|
||||||
|
URLPathAPIv1 = "/api/v1"
|
||||||
|
|
||||||
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
// JellyfinServerIDKey is the Property key for the stable, persisted server Id reported by the
|
||||||
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
// Jellyfin API. Jellyfin clients cache this value, so it must survive process restarts.
|
||||||
|
|
@ -154,8 +156,6 @@ 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 (
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func (p *localAgent) AgentName() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid string, count int) ([]Song, error) {
|
||||||
top, err := p.ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
top, err := p.ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Sort: "playCount",
|
Sort: "playCount",
|
||||||
Order: "desc",
|
Order: "desc",
|
||||||
Max: count,
|
Max: count,
|
||||||
|
|
@ -43,7 +43,7 @@ func (p *localAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbid
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artist, mbid string, count int) ([]Song, error) {
|
||||||
seed, err := p.ds.MediaFile(ctx).Get(id)
|
seed, err := p.ds.MediaFile().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ func (p *localAgent) GetSimilarSongsByTrack(ctx context.Context, id, name, artis
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
// Ask for extra so we can drop the seed itself and still fill the count.
|
// Ask for extra so we can drop the seed itself and still fill the count.
|
||||||
candidates, err := p.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
candidates, err := p.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{
|
Filters: squirrel.And{
|
||||||
persistence.SongGenres.ByID(genreIDs),
|
persistence.SongGenres.ByID(genreIDs),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,13 @@ type SessionKeys struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
func (sk *SessionKeys) Put(ctx context.Context, userId, sessionKey string) error {
|
||||||
return sk.DataStore.UserProps(ctx).Put(userId, sk.KeyName, sessionKey)
|
return sk.DataStore.UserProps().Put(ctx, userId, sk.KeyName, sessionKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
func (sk *SessionKeys) Get(ctx context.Context, userId string) (string, error) {
|
||||||
return sk.DataStore.UserProps(ctx).Get(userId, sk.KeyName)
|
return sk.DataStore.UserProps().Get(ctx, userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
func (sk *SessionKeys) Delete(ctx context.Context, userId string) error {
|
||||||
return sk.DataStore.UserProps(ctx).Delete(userId, sk.KeyName)
|
return sk.DataStore.UserProps().Delete(ctx, userId, sk.KeyName)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
core/archiver.go
188
core/archiver.go
|
|
@ -2,23 +2,32 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
"github.com/navidrome/navidrome/utils/str"
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const archiveCoverArtSize = 500
|
||||||
|
|
||||||
type Archiver interface {
|
type Archiver interface {
|
||||||
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipAlbum(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
ZipArtist(ctx context.Context, id string, format string, bitrate int, w io.Writer) error
|
||||||
|
|
@ -26,18 +35,20 @@ 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, ds model.DataStore, shares Share) Archiver {
|
func NewArchiver(ms stream.MediaStreamer, decider stream.TranscodeDecider, ds model.DataStore, shares Share, artwork artwork.Artwork) Archiver {
|
||||||
return &archiver{ds: ds, ms: ms, shares: shares}
|
return &archiver{ds: ds, ms: ms, decider: decider, 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipAlbum(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id})
|
return a.zipAlbums(ctx, id, format, bitrate, out, squirrel.Eq{"album_id": id}, model.ArtworkID{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
|
|
@ -47,28 +58,30 @@ func (a *archiver) ZipArtist(ctx context.Context, id string, format string, bitr
|
||||||
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
persistence.ParticipantIDFilter("media_file", id, model.RoleAlbumArtist),
|
||||||
squirrel.Eq{"missing": false},
|
squirrel.Eq{"missing": false},
|
||||||
}
|
}
|
||||||
return a.zipAlbums(ctx, id, format, bitrate, out, filter)
|
return a.zipAlbums(ctx, id, format, bitrate, out, filter, model.Artist{ID: id}.CoverArtID())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer) error {
|
// rootArt, when set, is added to the archive root.
|
||||||
mfs, err := a.ds.MediaFile(ctx).GetAll(model.QueryOptions{Filters: filters, Sort: "album"})
|
func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitrate int, out io.Writer, filters squirrel.Sqlizer, rootArt model.ArtworkID) error {
|
||||||
|
mfs, err := a.ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: filters, Sort: "album"})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from artist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
albums := slice.Group(mfs, func(mf model.MediaFile) string {
|
albums := slice.GroupOrdered(mfs, func(mf model.MediaFile) string { return mf.AlbumID })
|
||||||
return mf.AlbumID
|
folders := albumFolders(albums)
|
||||||
})
|
|
||||||
for _, album := range albums {
|
for _, album := range albums {
|
||||||
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
discs := slice.Group(album, func(mf model.MediaFile) int { return mf.DiscNumber })
|
||||||
isMultiDisc := len(discs) > 1
|
isMultiDisc := len(discs) > 1
|
||||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist,
|
folder := folders[album[0].AlbumID]
|
||||||
|
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 {
|
||||||
file := a.albumFilename(mf, format, isMultiDisc)
|
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
file := a.albumFilename(mf, req.Format, isMultiDisc, folder)
|
||||||
|
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
|
||||||
|
|
@ -78,7 +91,10 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||||
return addErr
|
return addErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// After the tracks, so a slow artwork lookup doesn't delay the first bytes.
|
||||||
|
a.addCoverArtToZip(ctx, z, album[0].AlbumCoverArtID(), folder)
|
||||||
}
|
}
|
||||||
|
a.addCoverArtToZip(ctx, z, rootArt, "")
|
||||||
err = z.Close()
|
err = z.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error closing zip file", "id", id, err)
|
log.Error(ctx, "Error closing zip file", "id", id, err)
|
||||||
|
|
@ -96,7 +112,61 @@ func createZipWriter(out io.Writer, format string, bitrate int) *zip.Writer {
|
||||||
return z
|
return z
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool) string {
|
// Tried in order; the first one whose values are distinct across the clashing albums wins.
|
||||||
|
// One album may have an empty value: it keeps the plain name, which the others can't clash with.
|
||||||
|
var albumDisambiguators = []func(model.MediaFile) string{
|
||||||
|
func(mf model.MediaFile) string { return mf.Tags.First(model.TagAlbumVersion) },
|
||||||
|
func(mf model.MediaFile) string {
|
||||||
|
// Reissues share Year (often the original's) but not ReleaseYear.
|
||||||
|
if y := cmp.Or(mf.ReleaseYear, mf.Year); y != 0 {
|
||||||
|
return strconv.Itoa(y)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
func(mf model.MediaFile) string { return mf.MbzAlbumType },
|
||||||
|
func(mf model.MediaFile) string { return mf.Tags.First(model.TagRecordLabel) },
|
||||||
|
func(mf model.MediaFile) string { return mf.CatalogNum },
|
||||||
|
func(mf model.MediaFile) string { return mf.AlbumID[:min(6, len(mf.AlbumID))] },
|
||||||
|
func(mf model.MediaFile) string { return mf.AlbumID },
|
||||||
|
}
|
||||||
|
|
||||||
|
// albumFolders maps each album id to its zip folder. Albums whose names sanitize to the
|
||||||
|
// same folder get a " [suffix]" from the first disambiguator that tells them all apart.
|
||||||
|
func albumFolders(albums [][]model.MediaFile) map[string]string {
|
||||||
|
byName := map[string][]model.MediaFile{}
|
||||||
|
for _, album := range albums {
|
||||||
|
name := str.SanitizeFilename(album[0].FullAlbumName())
|
||||||
|
byName[name] = append(byName[name], album[0])
|
||||||
|
}
|
||||||
|
folders := make(map[string]string, len(albums))
|
||||||
|
for name, group := range byName {
|
||||||
|
if len(group) == 1 {
|
||||||
|
folders[group[0].AlbumID] = name
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields:
|
||||||
|
for _, field := range albumDisambiguators {
|
||||||
|
ids := make(map[string]string, len(group)) // suffix -> album id
|
||||||
|
for _, mf := range group {
|
||||||
|
s := str.SanitizeFilename(field(mf))
|
||||||
|
if _, dup := ids[s]; dup {
|
||||||
|
continue fields
|
||||||
|
}
|
||||||
|
ids[s] = mf.AlbumID
|
||||||
|
}
|
||||||
|
for s, id := range ids {
|
||||||
|
folders[id] = name
|
||||||
|
if s != "" {
|
||||||
|
folders[id] = fmt.Sprintf("%s [%s]", name, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc bool, folder string) string {
|
||||||
_, file := filepath.Split(mf.Path)
|
_, file := filepath.Split(mf.Path)
|
||||||
if format != "raw" {
|
if format != "raw" {
|
||||||
file = strings.TrimSuffix(file, mf.Suffix) + format
|
file = strings.TrimSuffix(file, mf.Suffix) + format
|
||||||
|
|
@ -104,7 +174,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc
|
||||||
if isMultiDisc {
|
if isMultiDisc {
|
||||||
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file)
|
return fmt.Sprintf("%s/%s", folder, file)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
// ZipShare takes an already-loaded share: Share.Load records a visit, so
|
||||||
|
|
@ -114,27 +184,31 @@ func (a *archiver) ZipShare(ctx context.Context, s *model.Share, out io.Writer)
|
||||||
return model.ErrNotAuthorized
|
return model.ErrNotAuthorized
|
||||||
}
|
}
|
||||||
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
log.Debug(ctx, "Zipping share", "name", s.ID, "format", s.Format, "bitrate", s.MaxBitRate, "numTracks", len(s.Tracks))
|
||||||
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, false)
|
// The share is the authorization (as in the public image handler): an anonymous lookup would
|
||||||
|
// hide a private playlist. Only the cover read is elevated.
|
||||||
|
coverCtx := request.WithUser(ctx, model.User{IsAdmin: true})
|
||||||
|
return a.zipMediaFiles(ctx, s.ID, s.ID, s.Format, s.MaxBitRate, out, s.Tracks, coverCtx, s.CoverArtID(), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
func (a *archiver) ZipPlaylist(ctx context.Context, id string, format string, bitrate int, out io.Writer) error {
|
||||||
pls, err := a.ds.Playlist(ctx).GetWithTracks(id, true, false)
|
pls, err := a.ds.Playlist().GetWithTracks(ctx, id, true, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
log.Error(ctx, "Error loading mediafiles from playlist", "id", id, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mfs := pls.MediaFiles()
|
mfs := pls.MediaFiles()
|
||||||
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
log.Debug(ctx, "Zipping playlist", "name", pls.Name, "format", format, "bitrate", bitrate, "numTracks", len(mfs))
|
||||||
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, true)
|
return a.zipMediaFiles(ctx, id, pls.Name, format, bitrate, out, mfs, ctx, pls.CoverArtID(), true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, addM3U bool) error {
|
func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format string, bitrate int, out io.Writer, mfs model.MediaFiles, coverCtx context.Context, coverArt model.ArtworkID, addM3U bool) error {
|
||||||
z := createZipWriter(out, format, bitrate)
|
z := createZipWriter(out, format, bitrate)
|
||||||
|
|
||||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||||
for idx, mf := range mfs {
|
for idx, mf := range mfs {
|
||||||
file := a.playlistFilename(mf, format, idx)
|
req := a.resolveRequest(ctx, &mf, format, bitrate)
|
||||||
if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) {
|
file := a.playlistFilename(mf, req.Format, idx)
|
||||||
|
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()
|
||||||
|
|
@ -143,6 +217,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||||
mf.Path = file
|
mf.Path = file
|
||||||
zippedMfs[idx] = mf
|
zippedMfs[idx] = mf
|
||||||
}
|
}
|
||||||
|
a.addCoverArtToZip(coverCtx, z, coverArt, "")
|
||||||
|
|
||||||
// Add M3U file if requested
|
// Add M3U file if requested
|
||||||
if addM3U && len(zippedMfs) > 0 {
|
if addM3U && len(zippedMfs) > 0 {
|
||||||
|
|
@ -179,7 +254,14 @@ 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) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error {
|
func (a *archiver) resolveRequest(ctx context.Context, mf *model.MediaFile, format string, bitrate int) stream.Request {
|
||||||
|
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
|
||||||
|
|
@ -187,13 +269,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 format != "raw" && format != "" {
|
if req.Format != "raw" {
|
||||||
r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate})
|
r, err = a.ms.NewStream(ctx, &mf, req)
|
||||||
} 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", format, err)
|
log.Error(ctx, "Error opening file for zipping", "file", path, "format", req.Format, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
|
|
@ -220,3 +302,61 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addCoverArtToZip adds the cover as dir/folder.<ext>. Errors are logged, never returned.
|
||||||
|
func (a *archiver) addCoverArtToZip(ctx context.Context, z *zip.Writer, artID model.ArtworkID, dir string) {
|
||||||
|
if artID.ID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Buffered so a failed read leaves no empty entry.
|
||||||
|
data, err := a.readCoverArt(ctx, artID)
|
||||||
|
if errors.Is(err, artwork.ErrUnavailable) || errors.Is(err, model.ErrNotFound) {
|
||||||
|
log.Debug(ctx, "No cover art to add to zip", "artID", artID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Error reading cover art for zipping", "artID", artID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ext := coverArtExtension(data)
|
||||||
|
if ext == "" {
|
||||||
|
log.Warn(ctx, "Unknown cover art image type, not adding it to zip", "artID", artID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w, err := z.CreateHeader(&zip.FileHeader{
|
||||||
|
Name: path.Join(dir, "folder."+ext),
|
||||||
|
Modified: time.Now(),
|
||||||
|
Method: zip.Store,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Warn(ctx, "Error creating cover art zip entry", "artID", artID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = w.Write(data); err != nil {
|
||||||
|
log.Warn(ctx, "Error zipping cover art", "artID", artID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *archiver) readCoverArt(ctx context.Context, artID model.ArtworkID) ([]byte, error) {
|
||||||
|
img, err := a.artwork.Get(ctx, artID, archiveCoverArtSize, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer img.Close()
|
||||||
|
return io.ReadAll(img)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resizing may re-encode the image, so the type comes from its bytes.
|
||||||
|
func coverArtExtension(data []byte) string {
|
||||||
|
switch http.DetectContentType(data) {
|
||||||
|
case "image/jpeg":
|
||||||
|
return "jpg"
|
||||||
|
case "image/png":
|
||||||
|
return "png"
|
||||||
|
case "image/webp":
|
||||||
|
return "webp"
|
||||||
|
case "image/gif":
|
||||||
|
return "gif"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,18 @@ import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/core"
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/core/artwork"
|
||||||
"github.com/navidrome/navidrome/core/stream"
|
"github.com/navidrome/navidrome/core/stream"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/persistence"
|
"github.com/navidrome/navidrome/persistence"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
|
|
@ -21,15 +26,19 @@ var _ = Describe("Archiver", func() {
|
||||||
var (
|
var (
|
||||||
arch core.Archiver
|
arch core.Archiver
|
||||||
ms *mockMediaStreamer
|
ms *mockMediaStreamer
|
||||||
|
dc *fakeDecider
|
||||||
ds *mockDataStore
|
ds *mockDataStore
|
||||||
sh *mockShare
|
sh *mockShare
|
||||||
|
ca *mockCoverArt
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ms = &mockMediaStreamer{}
|
ms = &mockMediaStreamer{}
|
||||||
|
dc = &fakeDecider{}
|
||||||
sh = &mockShare{}
|
sh = &mockShare{}
|
||||||
ds = &mockDataStore{}
|
ds = &mockDataStore{}
|
||||||
arch = core.NewArchiver(ms, ds, sh)
|
ca = &mockCoverArt{images: map[string][]byte{}}
|
||||||
|
arch = core.NewArchiver(ms, dc, ds, sh, ca)
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipAlbum", func() {
|
Context("ZipAlbum", func() {
|
||||||
|
|
@ -45,7 +54,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile").Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(3)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -59,6 +68,23 @@ 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() {
|
||||||
|
|
@ -77,7 +103,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
|
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile").Return(mfRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -91,6 +117,140 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
Expect(zr.File[0].Name).To(Equal("Album 1/01 - track1.mp3"))
|
||||||
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
Expect(zr.File[1].Name).To(Equal("Album 1/02 - track2.mp3"))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
When("albums that share a name", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
})
|
||||||
|
|
||||||
|
// zipArtistEntries zips the given tracks as artist "1" and returns the entry names in zip order.
|
||||||
|
zipArtistEntries := func(mfs model.MediaFiles) []string {
|
||||||
|
mfRepo := &mockMediaFileRepository{}
|
||||||
|
mfRepo.On("GetAll", mock.Anything).Return(mfs, nil)
|
||||||
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||||
|
Expect(err).To(BeNil())
|
||||||
|
names := make([]string, len(zr.File))
|
||||||
|
for i, f := range zr.File {
|
||||||
|
names[i] = f.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
It("keeps the albums in query order", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Album C"},
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||||
|
{Path: "a/02.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album A"},
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album B"},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Album C/01.mp3", "Album A/01.mp3", "Album A/02.mp3", "Album B/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("suffixes the year when it tells the albums apart", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01 - Intro.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01 - Intro.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01 - Intro.mp3", "Greatest Hits [2005]/01 - Intro.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers the release year, so reissues of the same original are told apart", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 1996, ReleaseYear: 2011},
|
||||||
|
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 1996},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2011]/01.mp3", "Greatest Hits [1996]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("names the folder after the full album name", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits (Original)/01.mp3", "Greatest Hits (CD_Digital)/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers the album version over the year when it is not part of the name", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Original"}}},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [Original]/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves the one album without the field unsuffixed", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Greatest Hits [Deluxe Edition]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips a field that is empty on more than one album", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005},
|
||||||
|
{Path: "c/01.mp3", Suffix: "mp3", AlbumID: "3", Album: "Greatest Hits", Year: 2010,
|
||||||
|
Tags: model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [2001]/01.mp3", "Greatest Hits [2005]/01.mp3", "Greatest Hits [2010]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("skips a field that is the same on every album", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-1"},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Live", Year: 2001, MbzAlbumType: "album", CatalogNum: "CAT-2"},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Live [CAT-1]/01.mp3", "Live [CAT-2]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("falls back to the album id when nothing differs", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "0123456789abcdef", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "fedcba9876543210", Album: "Greatest Hits", Year: 2001},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits [012345]/01.mp3", "Greatest Hits [fedcba]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("treats names that sanitize to the same folder as a clash", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "A/B", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: `A\B`, Year: 2005},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"A_B [2001]/01.mp3", "A_B [2005]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("sanitizes the suffix", func() {
|
||||||
|
conf.Server.Subsonic.AppendAlbumVersion = false
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"Vinyl"}}},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Hits", Tags: model.Tags{model.TagAlbumVersion: {"CD/Digital"}}},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Hits [Vinyl]/01.mp3", "Hits [CD_Digital]/01.mp3"}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves the folder name alone when only one album has it", func() {
|
||||||
|
names := zipArtistEntries(model.MediaFiles{
|
||||||
|
{Path: "a/01.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001},
|
||||||
|
{Path: "b/01.mp3", Suffix: "mp3", AlbumID: "2", Album: "Other", Year: 2005},
|
||||||
|
})
|
||||||
|
Expect(names).To(Equal([]string{"Greatest Hits/01.mp3", "Other/01.mp3"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("when the transcode limiter rejects a file", func() {
|
Context("when the transcode limiter rejects a file", func() {
|
||||||
|
|
@ -105,7 +265,7 @@ var _ = Describe("Archiver", func() {
|
||||||
Filters: squirrel.Eq{"album_id": "1"},
|
Filters: squirrel.Eq{"album_id": "1"},
|
||||||
Sort: "album",
|
Sort: "album",
|
||||||
}}).Return(mfs, nil)
|
}}).Return(mfs, nil)
|
||||||
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
ds.On("MediaFile").Return(mfRepo)
|
||||||
|
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).
|
||||||
Return(nil, stream.ErrTooManyTranscodes).Once()
|
Return(nil, stream.ErrTooManyTranscodes).Once()
|
||||||
|
|
@ -155,6 +315,30 @@ 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"}},
|
||||||
|
|
@ -169,7 +353,7 @@ var _ = Describe("Archiver", func() {
|
||||||
|
|
||||||
plRepo := &mockPlaylistRepository{}
|
plRepo := &mockPlaylistRepository{}
|
||||||
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
plRepo.On("GetWithTracks", "1", true, false).Return(pls, nil)
|
||||||
ds.On("Playlist", mock.Anything).Return(plRepo)
|
ds.On("Playlist").Return(plRepo)
|
||||||
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}).Return(io.NopCloser(strings.NewReader("test")), nil).Times(2)
|
||||||
|
|
||||||
out := new(bytes.Buffer)
|
out := new(bytes.Buffer)
|
||||||
|
|
@ -196,24 +380,195 @@ var _ = Describe("Archiver", func() {
|
||||||
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
Expect(string(m3uContent)).To(Equal(expectedM3U))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
Context("cover art", func() {
|
||||||
|
var (
|
||||||
|
jpegData = []byte("\xff\xd8\xff\xe0 fake jpeg")
|
||||||
|
pngData = []byte("\x89PNG\x0d\x0a\x1a\x0a fake png")
|
||||||
|
)
|
||||||
|
|
||||||
|
mockAlbumTracks := func(filter squirrel.Sqlizer, mfs model.MediaFiles) {
|
||||||
|
mfRepo := &mockMediaFileRepository{}
|
||||||
|
mfRepo.On("GetAll", []model.QueryOptions{{Filters: filter, Sort: "album"}}).Return(mfs, nil)
|
||||||
|
ds.On("MediaFile", mock.Anything).Return(mfRepo)
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
It("adds the album cover to the album folder", func() {
|
||||||
|
ca.images["al-1"] = jpegData
|
||||||
|
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album/Promo", DiscNumber: 1},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(2))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Album_Promo/folder.jpg", jpegData))
|
||||||
|
Expect(ca.requests).To(ConsistOf(coverRequest{id: "al-1", size: 500, square: false}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("adds the artist image to the root and each album cover to its folder", func() {
|
||||||
|
ca.images["ar-1"] = pngData
|
||||||
|
ca.images["al-1"] = jpegData
|
||||||
|
ca.images["al-2"] = jpegData
|
||||||
|
mockAlbumTracks(squirrel.And{
|
||||||
|
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||||
|
squirrel.Eq{"missing": false},
|
||||||
|
}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album 1", DiscNumber: 1},
|
||||||
|
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Album 2", DiscNumber: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(5))
|
||||||
|
Expect(files).To(HaveKeyWithValue("folder.png", pngData))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Album 1/folder.jpg", jpegData))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Album 2/folder.jpg", jpegData))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("puts each same-named album's cover in that album's own folder", func() {
|
||||||
|
ca.images["al-1"] = jpegData
|
||||||
|
ca.images["al-2"] = pngData
|
||||||
|
mockAlbumTracks(squirrel.And{
|
||||||
|
persistence.ParticipantIDFilter("media_file", "1", model.RoleAlbumArtist),
|
||||||
|
squirrel.Eq{"missing": false},
|
||||||
|
}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Greatest Hits", Year: 2001, DiscNumber: 1},
|
||||||
|
{Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "2", Album: "Greatest Hits", Year: 2005, DiscNumber: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipArtist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveKeyWithValue("Greatest Hits [2001]/folder.jpg", jpegData))
|
||||||
|
Expect(files).To(HaveKeyWithValue("Greatest Hits [2005]/folder.png", pngData))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("adds the playlist cover to the root", func() {
|
||||||
|
ca.images["pl-1"] = jpegData
|
||||||
|
plRepo := &mockPlaylistRepository{}
|
||||||
|
plRepo.On("GetWithTracks", "1", true, false).Return(&model.Playlist{
|
||||||
|
ID: "1",
|
||||||
|
Name: "Test Playlist",
|
||||||
|
Tracks: []model.PlaylistTrack{
|
||||||
|
{MediaFile: model.MediaFile{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Artist: "Artist 1", Title: "track1"}},
|
||||||
|
},
|
||||||
|
}, nil)
|
||||||
|
ds.On("Playlist", mock.Anything).Return(plRepo)
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipPlaylist(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(3))
|
||||||
|
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||||
|
Expect(files).To(HaveKey("Test Playlist.m3u"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("adds the shared item's cover to the root, even for a private playlist", func() {
|
||||||
|
ca.images["pl-10"] = jpegData
|
||||||
|
ms.On("NewStream", mock.Anything, mock.Anything, mock.Anything).Return(io.NopCloser(strings.NewReader("test")), nil)
|
||||||
|
share := &model.Share{
|
||||||
|
ID: "1",
|
||||||
|
Downloadable: true,
|
||||||
|
Format: "mp3",
|
||||||
|
MaxBitRate: 128,
|
||||||
|
ResourceType: "playlist",
|
||||||
|
ResourceIDs: "10",
|
||||||
|
Tracks: model.MediaFiles{
|
||||||
|
{ID: "1", Path: "test_data/01 - track1.mp3", Suffix: "mp3", Artist: "Artist 1", Title: "track1"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipShare(context.Background(), share, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(2))
|
||||||
|
Expect(files).To(HaveKeyWithValue("folder.jpg", jpegData))
|
||||||
|
Expect(ca.requests).To(ConsistOf(coverRequest{id: "pl-10", size: 500, square: false, admin: true}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("still builds the archive when the cover cannot be read", func() {
|
||||||
|
ca.err = errors.New("boom")
|
||||||
|
mockAlbumTracks(squirrel.Eq{"album_id": "1"}, model.MediaFiles{
|
||||||
|
{Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1},
|
||||||
|
})
|
||||||
|
|
||||||
|
out := new(bytes.Buffer)
|
||||||
|
Expect(arch.ZipAlbum(context.Background(), "1", "mp3", 128, out)).To(Succeed())
|
||||||
|
|
||||||
|
files := readZip(out)
|
||||||
|
Expect(files).To(HaveLen(1))
|
||||||
|
Expect(files).To(HaveKey("Album/01 - track1.mp3"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
func readZip(out *bytes.Buffer) map[string][]byte {
|
||||||
|
zr, err := zip.NewReader(bytes.NewReader(out.Bytes()), int64(out.Len()))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
files := make(map[string][]byte, len(zr.File))
|
||||||
|
for _, f := range zr.File {
|
||||||
|
r, err := f.Open()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
_ = r.Close()
|
||||||
|
files[f.Name] = data
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
|
type coverRequest struct {
|
||||||
|
id string
|
||||||
|
size int
|
||||||
|
square bool
|
||||||
|
admin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockCoverArt struct {
|
||||||
|
artwork.Artwork
|
||||||
|
images map[string][]byte
|
||||||
|
err error
|
||||||
|
requests []coverRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCoverArt) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (*artwork.Image, error) {
|
||||||
|
user, _ := request.UserFrom(ctx)
|
||||||
|
m.requests = append(m.requests, coverRequest{id: artID.String(), size: size, square: square, admin: user.IsAdmin})
|
||||||
|
if m.err != nil {
|
||||||
|
return nil, m.err
|
||||||
|
}
|
||||||
|
data, ok := m.images[artID.String()]
|
||||||
|
if !ok {
|
||||||
|
return nil, artwork.ErrUnavailable
|
||||||
|
}
|
||||||
|
return &artwork.Image{ReadCloser: io.NopCloser(bytes.NewReader(data))}, nil
|
||||||
|
}
|
||||||
|
|
||||||
type mockDataStore struct {
|
type mockDataStore struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
model.DataStore
|
model.DataStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) MediaFile(ctx context.Context) model.MediaFileRepository {
|
func (m *mockDataStore) MediaFile() model.MediaFileRepository {
|
||||||
args := m.Called(ctx)
|
args := m.Called()
|
||||||
return args.Get(0).(model.MediaFileRepository)
|
return args.Get(0).(model.MediaFileRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Playlist(ctx context.Context) model.PlaylistRepository {
|
func (m *mockDataStore) Playlist() model.PlaylistRepository {
|
||||||
args := m.Called(ctx)
|
args := m.Called()
|
||||||
return args.Get(0).(model.PlaylistRepository)
|
return args.Get(0).(model.PlaylistRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockDataStore) Library(context.Context) model.LibraryRepository {
|
func (m *mockDataStore) Library() model.LibraryRepository {
|
||||||
return &mockLibraryRepository{}
|
return &mockLibraryRepository{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +577,7 @@ type mockLibraryRepository struct {
|
||||||
model.LibraryRepository
|
model.LibraryRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockLibraryRepository) GetPath(id int) (string, error) {
|
func (m *mockLibraryRepository) GetPath(_ context.Context, id int) (string, error) {
|
||||||
return "/music", nil
|
return "/music", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -231,7 +586,7 @@ type mockMediaFileRepository struct {
|
||||||
model.MediaFileRepository
|
model.MediaFileRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockMediaFileRepository) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepository) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
args := m.Called(options)
|
args := m.Called(options)
|
||||||
return args.Get(0).(model.MediaFiles), args.Error(1)
|
return args.Get(0).(model.MediaFiles), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -241,7 +596,7 @@ type mockPlaylistRepository struct {
|
||||||
model.PlaylistRepository
|
model.PlaylistRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlaylistRepository) GetWithTracks(id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
func (m *mockPlaylistRepository) GetWithTracks(_ context.Context, id string, refreshSmartPlaylists, includeMissing bool) (*model.Playlist, error) {
|
||||||
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
args := m.Called(id, refreshSmartPlaylists, includeMissing)
|
||||||
return args.Get(0).(*model.Playlist), args.Error(1)
|
return args.Get(0).(*model.Playlist), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
@ -259,6 +614,19 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -59,21 +59,21 @@ func entityExists(ctx context.Context, ds model.DataStore, artID model.ArtworkID
|
||||||
var err error
|
var err error
|
||||||
switch artID.Kind {
|
switch artID.Kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
found, err = ds.Artist(ctx).Exists(artID.ID)
|
found, err = ds.Artist().Exists(ctx, artID.ID)
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
found, err = ds.Album(ctx).Exists(artID.ID)
|
found, err = ds.Album().Exists(ctx, artID.ID)
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
found, err = ds.MediaFile(ctx).Exists(artID.ID)
|
found, err = ds.MediaFile().Exists(ctx, artID.ID)
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
found, err = ds.Playlist(ctx).Exists(artID.ID)
|
found, err = ds.Playlist().Exists(ctx, artID.ID)
|
||||||
case model.KindRadioArtwork:
|
case model.KindRadioArtwork:
|
||||||
found, err = ds.Radio(ctx).Exists(artID.ID)
|
found, err = ds.Radio().Exists(ctx, artID.ID)
|
||||||
case model.KindDiscArtwork:
|
case model.KindDiscArtwork:
|
||||||
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
albumID, _, perr := model.ParseDiscArtworkID(artID.ID)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
found, err = ds.Album(ctx).Exists(albumID)
|
found, err = ds.Album().Exists(ctx, albumID)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +119,7 @@ func (s *service) Get(ctx context.Context, artID model.ArtworkID, size int, squa
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
func (s *service) serveEntity(ctx context.Context, artID model.ArtworkID, size int, square bool) (*Image, error) {
|
||||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(artID.Kind, artID.ID, model.ImageTypePrimary)
|
ia, err := s.ds.Artwork().GetItemArtwork(ctx, artID.Kind, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, model.ErrNotFound):
|
case errors.Is(err, model.ErrNotFound):
|
||||||
return s.provisional(ctx, artID, size, square)
|
return s.provisional(ctx, artID, size, square)
|
||||||
|
|
@ -173,7 +173,7 @@ func (s *service) serveHash(ctx context.Context, artID model.ArtworkID, ia *mode
|
||||||
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
log.Warn(ctx, "Artwork: Stored source is not an image file, re-resolving", "artID", artID, "path", ia.SourcePath)
|
||||||
return s.dangling(ctx, artID)
|
return s.dangling(ctx, artID)
|
||||||
}
|
}
|
||||||
art, err := s.ds.Artwork(ctx).GetImage(ia.Hash)
|
art, err := s.ds.Artwork().GetImage(ctx, ia.Hash)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
return s.dangling(ctx, artID)
|
return s.dangling(ctx, artID)
|
||||||
|
|
@ -264,13 +264,13 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
||||||
// 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(ctx).Get(artID.ID)
|
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
return s.Get(ctx, mf.DiscCoverArtID(), size, square)
|
||||||
}
|
}
|
||||||
ia, err := s.ds.Artwork(ctx).GetItemArtwork(model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
ia, err := s.ds.Artwork().GetItemArtwork(ctx, model.KindMediaFileArtwork, artID.ID, model.ImageTypePrimary)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && ia.Hash != "":
|
case err == nil && ia.Hash != "":
|
||||||
return s.serveHash(ctx, artID, ia, size, square)
|
return s.serveHash(ctx, artID, ia, size, square)
|
||||||
|
|
@ -283,7 +283,7 @@ func (s *service) serveMediaFile(ctx context.Context, artID model.ArtworkID, siz
|
||||||
}
|
}
|
||||||
noRow := errors.Is(err, model.ErrNotFound)
|
noRow := errors.Is(err, model.ErrNotFound)
|
||||||
|
|
||||||
mf, err := s.ds.MediaFile(ctx).Get(artID.ID)
|
mf, err := s.ds.MediaFile().Get(ctx, artID.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -342,7 +342,7 @@ func (s *service) dangling(ctx context.Context, artID model.ArtworkID) (*Image,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
func (s *service) enqueue(ctx context.Context, artID model.ArtworkID, priority int) {
|
||||||
err := s.ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
err := s.ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: artID.Kind.Prefix(),
|
ItemKind: artID.Kind.Prefix(),
|
||||||
ItemID: artID.ID,
|
ItemID: artID.ID,
|
||||||
ImageType: model.ImageTypePrimary,
|
ImageType: model.ImageTypePrimary,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
@ -108,15 +109,15 @@ type fakeFolderRepo struct {
|
||||||
otherAudioErr error
|
otherAudioErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) {
|
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||||
return f.result, f.err
|
return f.result, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||||
return f.hasOtherAudio, f.otherAudioErr
|
return f.hasOtherAudio, f.otherAudioErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) {
|
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||||
f.getCallCount++
|
f.getCallCount++
|
||||||
if f.getErr != nil {
|
if f.getErr != nil {
|
||||||
return nil, f.getErr
|
return nil, f.getErr
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,8 @@ var _ = Describe("Artwork", func() {
|
||||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
Expect(store.Write(hash, "image/jpeg", bytes.NewReader(imgBytes))).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "image/jpeg"})).To(Succeed())
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: kind, ItemID: id, Hash: hash, Source: "external"})).To(Succeed())
|
||||||
seedEntity(kind, id)
|
seedEntity(kind, id)
|
||||||
return hash
|
return hash
|
||||||
}
|
}
|
||||||
|
|
@ -56,9 +56,9 @@ var _ = Describe("Artwork", func() {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
switch kind {
|
switch kind {
|
||||||
case "al":
|
case "al":
|
||||||
Expect(albumRepo.Put(&model.Album{ID: id, Name: "Album"})).To(Succeed())
|
Expect(albumRepo.Put(ctx, &model.Album{ID: id, Name: "Album"})).To(Succeed())
|
||||||
case "mf":
|
case "mf":
|
||||||
Expect(mfRepo.Put(&model.MediaFile{ID: id})).To(Succeed())
|
Expect(mfRepo.Put(ctx, &model.MediaFile{ID: id})).To(Succeed())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -147,9 +147,9 @@ var _ = Describe("Artwork", func() {
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
mtime := fileMtime(imgPath)
|
mtime := fileMtime(imgPath)
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "aaaaaaaaaaaaaaaa", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al2")
|
seedEntity("al", "al2")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
ItemKind: "al", ItemID: "al2", Hash: "aaaaaaaaaaaaaaaa",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
Source: "folder", SourcePath: imgPath, RefMtime: mtime,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -163,9 +163,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
secretPath := filepath.Join(dir, "config.ini")
|
||||||
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
Expect(os.WriteFile(secretPath, []byte("password=secret"), 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "dddddddddddddddd", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "alni")
|
seedEntity("al", "alni")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
ItemKind: "al", ItemID: "alni", Hash: "dddddddddddddddd",
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -180,9 +180,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
secretPath := filepath.Join(dir, "config.ini")
|
secretPath := filepath.Join(dir, "config.ini")
|
||||||
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
Expect(os.WriteFile(secretPath, secret, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "eeeeeeeeeeeeeeee", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "alnic")
|
seedEntity("al", "alnic")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
ItemKind: "al", ItemID: "alnic", Hash: "eeeeeeeeeeeeeeee",
|
||||||
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
Source: "folder", SourcePath: secretPath, RefMtime: fileMtime(secretPath),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -210,9 +210,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "bbbbbbbbbbbbbbbb", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3")
|
seedEntity("al", "al3")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
ItemKind: "al", ItemID: "al3", Hash: "bbbbbbbbbbbbbbbb",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -220,7 +220,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al3"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
Expect(queueRepo.Data[primaryKey("al", "al3")].Priority).To(Equal(model.ArtworkPriorityScan))
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
Expect(ia.Hash).To(Equal("bbbbbbbbbbbbbbbb"))
|
||||||
})
|
})
|
||||||
|
|
@ -229,9 +229,9 @@ var _ = Describe("Artwork", func() {
|
||||||
dir := GinkgoT().TempDir()
|
dir := GinkgoT().TempDir()
|
||||||
imgPath := filepath.Join(dir, "cover.jpg")
|
imgPath := filepath.Join(dir, "cover.jpg")
|
||||||
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
Expect(os.WriteFile(imgPath, coverBytes, 0600)).To(Succeed())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: "cccccccccccccccc", Mime: "image/jpeg"})).To(Succeed())
|
||||||
seedEntity("al", "al3b")
|
seedEntity("al", "al3b")
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
ItemKind: "al", ItemID: "al3b", Hash: "cccccccccccccccc",
|
||||||
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
Source: "folder", SourcePath: imgPath, RefMtime: fileMtime(imgPath) + 999,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -252,7 +252,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("never re-enqueues an absent state on view, however old", func() {
|
It("never re-enqueues an absent state on view, however old", func() {
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
ItemKind: "al", ItemID: "al4", AttemptedAt: time.Now().Add(-365 * 24 * time.Hour),
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -272,7 +272,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(readAll(img)).To(Equal(coverBytes))
|
Expect(readAll(img)).To(Equal(coverBytes))
|
||||||
|
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al5")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -283,7 +283,7 @@ var _ = Describe("Artwork", func() {
|
||||||
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
_, err := svc.Get(ctx, model.MustParseArtworkID("al-al6"), 0, false)
|
||||||
Expect(err).To(MatchError(ErrUnavailable))
|
Expect(err).To(MatchError(ErrUnavailable))
|
||||||
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("al", "al6")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -310,7 +310,7 @@ var _ = Describe("Artwork", func() {
|
||||||
|
|
||||||
It("delegates to the album when the track's state is absent", func() {
|
It("delegates to the album when the track's state is absent", func() {
|
||||||
seedFoundStore("al", "albm", coverBytes)
|
seedFoundStore("al", "albm", coverBytes)
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "mf", ItemID: "mf2"})).To(Succeed())
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
mfRepo.SetData(model.MediaFiles{{ID: "mf2", AlbumID: "albm"}})
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("mf-mf2"), 0, false)
|
||||||
|
|
@ -343,7 +343,7 @@ var _ = Describe("Artwork", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
Expect(queueRepo.Data[primaryKey("mf", "mf4")].Priority).To(Equal(model.ArtworkPriorityBump))
|
||||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -494,7 +494,7 @@ var _ = Describe("Artwork", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to the artist placeholder for an absent artist", func() {
|
It("falls back to the artist placeholder for an absent artist", func() {
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "arph"})).To(Succeed())
|
||||||
|
|
||||||
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
img, err := svc.GetOrPlaceholder(ctx, "ar-arph", 300, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -535,7 +535,7 @@ var _ = Describe("EntityExists", func() {
|
||||||
artistRepo := tests.CreateMockArtistRepo()
|
artistRepo := tests.CreateMockArtistRepo()
|
||||||
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
artistRepo.SetData(model.Artists{{ID: "ar1"}})
|
||||||
radioRepo := tests.CreateMockedRadioRepo()
|
radioRepo := tests.CreateMockedRadioRepo()
|
||||||
Expect(radioRepo.Put(&model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
Expect(radioRepo.Put(ctx, &model.Radio{ID: "ra1", Name: "R"})).To(Succeed())
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
ds = &tests.MockDataStore{MockedAlbum: albumRepo, MockedArtist: artistRepo, MockedRadio: radioRepo}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
return nil, fmt.Errorf("invalid disc artwork id '%s': %w", artID.ID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
al, err := ds.Album(ctx).Get(albumID)
|
al, err := ds.Album().Get(ctx, albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -61,7 +61,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query mediafiles for this album + disc to find folder associations and first track
|
// Query mediafiles for this album + disc to find folder associations and first track
|
||||||
mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{
|
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
Sort: "track_number",
|
Sort: "track_number",
|
||||||
Order: "ASC",
|
Order: "ASC",
|
||||||
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
Filters: squirrel.Eq{"album_id": albumID, "disc_number": discNumber},
|
||||||
|
|
@ -88,7 +88,7 @@ func newDiscArtworkReader(ctx context.Context, ds model.DataStore, artID model.A
|
||||||
// Resolve folder IDs to library-relative paths
|
// Resolve folder IDs to library-relative paths
|
||||||
discFoldersRel := make(map[string]bool)
|
discFoldersRel := make(map[string]bool)
|
||||||
if len(folderIDs) > 0 {
|
if len(folderIDs) > 0 {
|
||||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
|
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"folder.id": folderIDs},
|
Filters: squirrel.Eq{"folder.id": folderIDs},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -44,20 +44,20 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
itemFound := func(kind model.Kind, id string) func() bool {
|
itemFound := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
itemAbsent := func(kind model.Kind, id string) func() bool {
|
itemAbsent := func(kind model.Kind, id string) func() bool {
|
||||||
return func() bool {
|
return func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(kind, id, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, kind, id, model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash == ""
|
return err == nil && ia.Hash == ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||||
bump := func(kind, id string) {
|
bump := func(kind, id string) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
Expect(ds.ArtworkQueue(ctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
Expect(ds.ArtworkQueue().EnqueuePreservingBackoff(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
ItemKind: kind, ItemID: id, ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBump,
|
Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
@ -141,7 +141,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
||||||
|
|
@ -158,7 +158,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ar", "ar1")
|
bump("ar", "ar1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindArtistArtwork, "ar1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -175,14 +175,14 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("pl", "pl1")
|
bump("pl", "pl1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindPlaylistArtwork, "pl1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "pl1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("pl-pl1"), 0, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(img.Hash).To(Equal(ia.Hash))
|
Expect(img.Hash).To(Equal(ia.Hash))
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/png"))
|
Expect(art.Mime).To(Equal("image/png"))
|
||||||
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
Expect(len(readAll(img))).To(BeNumerically(">", 0))
|
||||||
|
|
@ -194,7 +194,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
||||||
|
|
@ -216,12 +216,12 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
provisionalBytes := readAll(provisional)
|
provisionalBytes := readAll(provisional)
|
||||||
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
Expect(len(provisionalBytes)).To(BeNumerically(">", 0))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
Expect(err).To(MatchError(model.ErrNotFound), "provisional serving must not write a state row")
|
||||||
|
|
||||||
// The provisional read enqueued a Bump; drain it.
|
// The provisional read enqueued a Bump; drain it.
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindMediaFileArtwork, "mf1"))
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(ia.Hash).To(Equal(provisional.Hash))
|
Expect(ia.Hash).To(Equal(provisional.Hash))
|
||||||
|
|
@ -237,9 +237,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("al", "al1")
|
bump("al", "al1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindAlbumArtwork, "al1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/jpeg"))
|
Expect(art.Mime).To(Equal("image/jpeg"))
|
||||||
Expect(art.Width).To(BeNumerically(">", 0))
|
Expect(art.Width).To(BeNumerically(">", 0))
|
||||||
|
|
@ -259,9 +259,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Mime).To(Equal("image/gif"))
|
Expect(art.Mime).To(Equal("image/gif"))
|
||||||
Expect(art.Width).To(BeNumerically("==", 4))
|
Expect(art.Width).To(BeNumerically("==", 4))
|
||||||
|
|
@ -279,9 +279,9 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
return itemFound(model.KindAlbumArtwork, "al1")() && itemFound(model.KindAlbumArtwork, "al2")()
|
||||||
})
|
})
|
||||||
|
|
||||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
Expect(ia1.Hash).To(Equal(ia2.Hash), "identical bytes must share one content hash")
|
||||||
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
Expect(readAll(mustGet(svc.Get(ctx, model.MustParseArtworkID("al-al2"), 0, false)))).To(Equal(coverBytes))
|
||||||
|
|
@ -293,7 +293,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
bump("ra", "ra1")
|
bump("ra", "ra1")
|
||||||
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
runWorkerUntil(ctx, worker, itemFound(model.KindRadioArtwork, "ra1"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
staleHash := ia.Hash
|
staleHash := ia.Hash
|
||||||
|
|
||||||
|
|
@ -308,7 +308,7 @@ var _ = Describe("Acquisition → serve loop", func() {
|
||||||
|
|
||||||
// That failed read enqueued a re-resolution.
|
// That failed read enqueued a re-resolution.
|
||||||
runWorkerUntil(ctx, worker, func() bool {
|
runWorkerUntil(ctx, worker, func() bool {
|
||||||
cur, gerr := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
cur, gerr := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
return gerr == nil && cur.Hash != "" && cur.Hash != staleHash
|
||||||
})
|
})
|
||||||
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
img, err := svc.Get(ctx, model.MustParseArtworkID("ra-ra1"), 0, false)
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
uploaded := ar.ID + "_upload.jpg"
|
uploaded := ar.ID + "_upload.jpg"
|
||||||
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
writeUploadedImage(consts.EntityArtist, uploaded, pngBytes("artist-uploaded"))
|
||||||
ar.UploadedImage = uploaded
|
ar.UploadedImage = uploaded
|
||||||
Expect(rds.Artist(rctx).Put(&ar)).To(Succeed())
|
Expect(rds.Artist().Put(rctx, &ar)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindArtistArtwork, ar.ID)
|
ia := acquire(model.KindArtistArtwork, ar.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -279,7 +279,7 @@ var _ = Describe("Artist artwork resolution", func() {
|
||||||
|
|
||||||
func soleArtist() model.Artist {
|
func soleArtist() model.Artist {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
artists, err := rds.Artist(rctx).GetAll(model.QueryOptions{
|
artists, err := rds.Artist().GetAll(rctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.name": "Artist"},
|
Filters: squirrel.Eq{"artist.name": "Artist"},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -67,13 +67,17 @@ type fakeFolderRepo struct {
|
||||||
result []model.Folder
|
result []model.Folder
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) GetAll(...model.QueryOptions) ([]model.Folder, error) { return f.result, nil }
|
func (f *fakeFolderRepo) GetAll(context.Context, ...model.QueryOptions) ([]model.Folder, error) {
|
||||||
|
return f.result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) HasAudioOutsideFolders(model.Folder, []string) (bool, error) {
|
func (f *fakeFolderRepo) HasAudioOutsideFolders(context.Context, model.Folder, []string) (bool, error) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFolderRepo) Get(string) (*model.Folder, error) { return nil, model.ErrNotFound }
|
func (f *fakeFolderRepo) Get(context.Context, string) (*model.Folder, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func writeUpload(entityType, name, srcFixture string) string {
|
func writeUpload(entityType, name, srcFixture string) string {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ var _ = Describe("MediaFile artwork resolution", func() {
|
||||||
|
|
||||||
func mediafileOn(relPath string) model.MediaFile {
|
func mediafileOn(relPath string) model.MediaFile {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{
|
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{
|
||||||
Filters: squirrel.Like{"media_file.path": relPath},
|
Filters: squirrel.Like{"media_file.path": relPath},
|
||||||
})
|
})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -142,13 +142,13 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
})
|
})
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(2))
|
Expect(mfs).To(HaveLen(2))
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID})
|
||||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -180,14 +180,14 @@ var _ = Describe("Playlist artwork resolution", func() {
|
||||||
setLayout(layout)
|
setLayout(layout)
|
||||||
scan()
|
scan()
|
||||||
|
|
||||||
mfs, err := rds.MediaFile(rctx).GetAll(model.QueryOptions{})
|
mfs, err := rds.MediaFile().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(mfs).To(HaveLen(4))
|
Expect(mfs).To(HaveLen(4))
|
||||||
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
ids := slice.Map(mfs, func(mf model.MediaFile) string { return mf.ID })
|
||||||
|
|
||||||
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
pl := model.Playlist{ID: "pl-8", Name: "Four", OwnerID: "admin-1"}
|
||||||
pl.AddMediaFilesByID(ids)
|
pl.AddMediaFilesByID(ids)
|
||||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
ia := acquire(model.KindPlaylistArtwork, pl.ID)
|
||||||
Expect(ia.Source).To(Equal("generated"))
|
Expect(ia.Source).To(Equal("generated"))
|
||||||
|
|
@ -208,6 +208,6 @@ func putPlaylist(pl model.Playlist) model.Playlist {
|
||||||
if pl.OwnerID == "" {
|
if pl.OwnerID == "" {
|
||||||
pl.OwnerID = "admin-1"
|
pl.OwnerID = "admin-1"
|
||||||
}
|
}
|
||||||
Expect(rds.Playlist(rctx).Put(&pl)).To(Succeed())
|
Expect(rds.Playlist().Put(rctx, &pl)).To(Succeed())
|
||||||
return pl
|
return pl
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
It("returns the uploaded image bytes", func() {
|
It("returns the uploaded image bytes", func() {
|
||||||
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", pngBytes("radio-logo"))
|
||||||
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"}
|
||||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Source).To(Equal("upload"))
|
Expect(ia.Source).To(Equal("upload"))
|
||||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Radio artwork resolution", func() {
|
||||||
// (no files on disk — the resolver has no sources to fall back to)
|
// (no files on disk — the resolver has no sources to fall back to)
|
||||||
It("settles absent", func() {
|
It("settles absent", func() {
|
||||||
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"}
|
||||||
Expect(rds.Radio(rctx).Put(&rd)).To(Succeed())
|
Expect(rds.Radio().Put(rctx, &rd)).To(Succeed())
|
||||||
|
|
||||||
ia := acquire(model.KindRadioArtwork, rd.ID)
|
ia := acquire(model.KindRadioArtwork, rd.ID)
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,11 @@ func setupResolutionHarness() {
|
||||||
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
rds = &tests.MockDataStore{RealDS: persistence.New(db.Db())}
|
||||||
|
|
||||||
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"}
|
||||||
Expect(rds.User(rctx).Put(&adminUser)).To(Succeed())
|
Expect(rds.User().Put(rctx, &adminUser)).To(Succeed())
|
||||||
|
|
||||||
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath}
|
||||||
Expect(rds.Library(rctx).Put(&lib)).To(Succeed())
|
Expect(rds.Library().Put(rctx, &lib)).To(Succeed())
|
||||||
Expect(rds.User(rctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed())
|
Expect(rds.User().SetUserLibraries(rctx, adminUser.ID, []int{lib.ID})).To(Succeed())
|
||||||
|
|
||||||
loadEmbeddedFixture()
|
loadEmbeddedFixture()
|
||||||
|
|
||||||
|
|
@ -140,13 +140,13 @@ func scan() {
|
||||||
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
func acquire(kind model.Kind, id string) model.ItemArtwork {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
// Enqueues the way the serving paths do, so the drain is driven by a plain queue row.
|
||||||
Expect(rds.ArtworkQueue(rctx).EnqueuePreservingBackoff(model.ArtworkQueueItem{
|
Expect(rds.ArtworkQueue().EnqueuePreservingBackoff(rctx, model.ArtworkQueueItem{
|
||||||
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary,
|
||||||
Priority: model.ArtworkPriorityBump,
|
Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
var ia *model.ItemArtwork
|
var ia *model.ItemArtwork
|
||||||
runResolutionWorkerUntil(func() bool {
|
runResolutionWorkerUntil(func() bool {
|
||||||
got, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
got, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -211,7 +211,7 @@ func expectAlbumFolderCover(al model.Album, suffix string) {
|
||||||
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
// A drain settles every ready item, so byte-level folder assertions must precede any acquire.
|
||||||
func requireNoStateRow(kind model.Kind, id string) {
|
func requireNoStateRow(kind model.Kind, id string) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
_, err := rds.Artwork(rctx).GetItemArtwork(kind, id, model.ImageTypePrimary)
|
_, err := rds.Artwork().GetItemArtwork(rctx, kind, id, model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound),
|
Expect(err).To(MatchError(model.ErrNotFound),
|
||||||
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
"assert %s %q before acquiring any other entity in this spec", kind, id)
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +266,7 @@ func gridQuadrants(data []byte) [4]color.RGBA {
|
||||||
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
// Store-backed sources only (embedded/generated); file-backed ones assert on ia.SourcePath.
|
||||||
func storedBytes(ia model.ItemArtwork) []byte {
|
func storedBytes(ia model.ItemArtwork) []byte {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
art, err := rds.Artwork(rctx).GetImage(ia.Hash)
|
art, err := rds.Artwork().GetImage(rctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
r, err := rstore.Open(ia.Hash, art.Mime)
|
r, err := rstore.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -345,7 +345,7 @@ func replaceWithRealMP3(relPath string) {
|
||||||
|
|
||||||
func firstAlbum() model.Album {
|
func firstAlbum() model.Album {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums))
|
||||||
return albums[0]
|
return albums[0]
|
||||||
|
|
@ -353,7 +353,7 @@ func firstAlbum() model.Album {
|
||||||
|
|
||||||
func albumByName(name string) model.Album {
|
func albumByName(name string) model.Album {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
albums, err := rds.Album(rctx).GetAll(model.QueryOptions{})
|
albums, err := rds.Album().GetAll(rctx, model.QueryOptions{})
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
for _, al := range albums {
|
for _, al := range albums {
|
||||||
if al.Name == name {
|
if al.Name == name {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, album model.
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
func loadFolders(ctx context.Context, ds model.DataStore, folderIDs []string) ([]model.Folder, error) {
|
||||||
return ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
return ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderIDs, "missing": false}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// folderImages collects the folders' image files, sorted so files without
|
// folderImages collects the folders' image files, sorted so files without
|
||||||
|
|
@ -79,7 +79,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
||||||
if len(folders) < 2 && anyFolderHasImages(folders) {
|
if len(folders) < 2 && anyFolderHasImages(folders) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
parent, err := ds.Folder(ctx).Get(commonParentID)
|
parent, err := ds.Folder().Get(ctx, commonParentID)
|
||||||
if errors.Is(err, model.ErrNotFound) {
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
log.Warn(ctx, "Artwork: Parent folder not found for album cover art lookup", "parentID", commonParentID)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
@ -91,7 +91,7 @@ func albumRootParent(ctx context.Context, ds model.DataStore, folders []model.Fo
|
||||||
// The library root can never be an album root
|
// The library root can never be an album root
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
hasOtherAudio, err := ds.Folder(ctx).HasAudioOutsideFolders(*parent, folderIDs)
|
hasOtherAudio, err := ds.Folder().HasAudioOutsideFolders(ctx, *parent, folderIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Masterminds/squirrel"
|
"github.com/Masterminds/squirrel"
|
||||||
"github.com/navidrome/navidrome/core"
|
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils"
|
"github.com/navidrome/navidrome/utils"
|
||||||
|
|
@ -169,14 +168,15 @@ func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albu
|
||||||
folderPath = filepath.Dir(folderPath)
|
folderPath = filepath.Dir(folderPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Hacky, but the easiest way to get the folder ID ATM
|
// Cleaned like the album paths; Join keeps an empty path empty, Clean would return ".".
|
||||||
libPath := core.AbsolutePath(ctx, ds, libID, "")
|
libPath, _ := ds.Library().GetPath(ctx, libID)
|
||||||
|
libPath = filepath.Join(libPath)
|
||||||
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
folderID := model.FolderID(model.Library{ID: libID, Path: libPath}, folderPath)
|
||||||
|
|
||||||
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
log.Trace(ctx, "Artwork: Calculating artist folder details", "folderPath", folderPath, "folderID", folderID,
|
||||||
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
"libPath", libPath, "libID", libID, "albumPaths", paths)
|
||||||
|
|
||||||
folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
folders, err := ds.Folder().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"folder.id": folderID, "missing": false}})
|
||||||
if err != nil || len(folders) == 0 {
|
if err != nil || len(folders) == 0 {
|
||||||
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
log.Warn(ctx, "Artwork: Could not find folder for artist", "folderPath", folderPath, "id", folderID,
|
||||||
"libPath", libPath, "libID", libID, err)
|
"libPath", libPath, "libID", libID, err)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/core"
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
|
@ -47,11 +46,11 @@ var _ = Describe("loadArtistFolder", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
DeferCleanup(stubCoreAbsolutePath())
|
|
||||||
|
|
||||||
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
updatedAt = time.Now().Truncate(time.Second).Add(5 * time.Minute)
|
||||||
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
repo = &fakeFolderRepo{result: []model.Folder{{ImagesUpdatedAt: updatedAt}}}
|
||||||
ds = &tests.MockDataStore{MockedFolder: repo}
|
libRepo := &tests.MockLibraryRepo{}
|
||||||
|
libRepo.SetData(model.Libraries{{ID: 1, Path: filepath.FromSlash("/music")}})
|
||||||
|
ds = &tests.MockDataStore{MockedFolder: repo, MockedLibrary: libRepo}
|
||||||
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
albums = model.Albums{{LibraryID: 1, ID: "album1", Name: "Album 1"}}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -107,11 +106,3 @@ var _ = Describe("loadArtistFolder", func() {
|
||||||
Expect(upd).To(BeZero())
|
Expect(upd).To(BeZero())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
func stubCoreAbsolutePath() func() {
|
|
||||||
original := core.AbsolutePath
|
|
||||||
core.AbsolutePath = func(context.Context, model.DataStore, int, string) string {
|
|
||||||
return filepath.FromSlash("/music")
|
|
||||||
}
|
|
||||||
return func() { core.AbsolutePath = original }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func ConfigFingerprint() string {
|
||||||
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
|
// resolved under it. Nothing re-resolves on its own; applying a change is an explicit reprocess.
|
||||||
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||||
current := ConfigFingerprint()
|
current := ConfigFingerprint()
|
||||||
stored, err := ds.Property(ctx).DefaultGet(consts.ArtConfFingerprintPropertyKey, "")
|
stored, err := ds.Property().DefaultGet(ctx, consts.ArtConfFingerprintPropertyKey, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -89,14 +89,14 @@ func ReconcileConfigFingerprint(ctx context.Context, ds model.DataStore) error {
|
||||||
|
|
||||||
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
|
// MarkConfigApplied records the current fingerprint as the one the library is resolved under.
|
||||||
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
|
func MarkConfigApplied(ctx context.Context, ds model.DataStore) error {
|
||||||
return ds.Property(ctx).Put(consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
return ds.Property().Put(ctx, consts.ArtConfFingerprintPropertyKey, ConfigFingerprint())
|
||||||
}
|
}
|
||||||
|
|
||||||
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
// enqueueMissingAll is the safety net for entities a scan never enqueued (added between scans, or scanner off).
|
||||||
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||||
queue := ds.ArtworkQueue(ctx)
|
queue := ds.ArtworkQueue()
|
||||||
for _, kind := range ReprocessKinds {
|
for _, kind := range ReprocessKinds {
|
||||||
if _, err := queue.EnqueueAllMissing(kind, model.ArtworkPriorityRecheck); err != nil {
|
if _, err := queue.EnqueueAllMissing(ctx, kind, model.ArtworkPriorityRecheck); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -108,31 +108,31 @@ func enqueueMissingAll(ctx context.Context, ds model.DataStore) error {
|
||||||
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
func ItemName(ctx context.Context, ds model.DataStore, kind model.Kind, id string) (string, error) {
|
||||||
switch kind {
|
switch kind {
|
||||||
case model.KindArtistArtwork:
|
case model.KindArtistArtwork:
|
||||||
ar, err := ds.Artist(ctx).Get(id)
|
ar, err := ds.Artist().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return ar.Name, nil
|
return ar.Name, nil
|
||||||
case model.KindAlbumArtwork:
|
case model.KindAlbumArtwork:
|
||||||
al, err := ds.Album(ctx).Get(id)
|
al, err := ds.Album().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return al.Name, nil
|
return al.Name, nil
|
||||||
case model.KindPlaylistArtwork:
|
case model.KindPlaylistArtwork:
|
||||||
pls, err := ds.Playlist(ctx).Get(id)
|
pls, err := ds.Playlist().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return pls.Name, nil
|
return pls.Name, nil
|
||||||
case model.KindRadioArtwork:
|
case model.KindRadioArtwork:
|
||||||
rd, err := ds.Radio(ctx).Get(id)
|
rd, err := ds.Radio().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return rd.Name, nil
|
return rd.Name, nil
|
||||||
case model.KindMediaFileArtwork:
|
case model.KindMediaFileArtwork:
|
||||||
mf, err := ds.MediaFile(ctx).Get(id)
|
mf, err := ds.MediaFile().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -148,7 +148,7 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
al, err := ds.Album(ctx).Get(albumID)
|
al, err := ds.Album().Get(ctx, albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -162,11 +162,11 @@ func discArtworkName(ctx context.Context, ds model.DataStore, id string) (string
|
||||||
|
|
||||||
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
// Refresh drops an item's resolved artwork state and re-queues it at Bump priority.
|
||||||
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
|
func Refresh(ctx context.Context, ds model.DataStore, kind model.Kind, id string) error {
|
||||||
if err := ds.Artwork(ctx).DeleteForItems(kind, []string{id}); err != nil {
|
if err := ds.Artwork().DeleteForItems(ctx, kind, []string{id}); err != nil {
|
||||||
return fmt.Errorf("clearing artwork state: %w", err)
|
return fmt.Errorf("clearing artwork state: %w", err)
|
||||||
}
|
}
|
||||||
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
item := model.ArtworkQueueItem{ItemKind: kind.Prefix(), ItemID: id, ImageType: model.ImageTypePrimary, Priority: model.ArtworkPriorityBump}
|
||||||
if err := ds.ArtworkQueue(ctx).Enqueue(item); err != nil {
|
if err := ds.ArtworkQueue().Enqueue(ctx, item); err != nil {
|
||||||
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
return fmt.Errorf("enqueuing artwork refresh: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -95,15 +95,15 @@ var _ = Describe("Housekeeping", func() {
|
||||||
It("records the current fingerprint when none was ever stored", func() {
|
It("records the current fingerprint when none was ever stored", func() {
|
||||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||||
|
|
||||||
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal(ConfigFingerprint()))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
|
It("leaves a stale fingerprint stored, so the warning survives a restart", func() {
|
||||||
Expect(propRepo.Put(consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
Expect(propRepo.Put(ctx, consts.ArtConfFingerprintPropertyKey, "stale-fingerprint")).To(Succeed())
|
||||||
|
|
||||||
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
Expect(ReconcileConfigFingerprint(ctx, ds)).To(Succeed())
|
||||||
|
|
||||||
Expect(propRepo.Get(consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
Expect(propRepo.Get(ctx, consts.ArtConfFingerprintPropertyKey)).To(Equal("stale-fingerprint"))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -153,7 +153,7 @@ var _ = Describe("ItemName", func() {
|
||||||
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
{ID: "al-2", Name: "Sandinista!", Discs: model.Discs{2: "Side Three"}},
|
||||||
})
|
})
|
||||||
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
ds = &tests.MockDataStore{MockedAlbum: albumRepo}
|
||||||
Expect(ds.Artist(ctx).(*tests.MockArtistRepo).Put(&model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
Expect(ds.Artist().(*tests.MockArtistRepo).Put(ctx, &model.Artist{ID: "ar-1", Name: "Radiohead"})).To(Succeed())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns the album name", func() {
|
It("returns the album name", func() {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func (v libraryView) Abs(rel string) string {
|
||||||
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
// loadLibraryView resolves the MusicFS and absolute root path in a single
|
||||||
// library lookup.
|
// library lookup.
|
||||||
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) {
|
||||||
lib, err := ds.Library(ctx).Get(libID)
|
lib, err := ds.Library().Get(ctx, libID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return libraryView{}, err
|
return libraryView{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns a view for a library backed by registered storage", func() {
|
It("returns a view for a library backed by registered storage", func() {
|
||||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
Expect(ds.Library().Put(ctx, &model.Library{ID: 1, Path: "fake:///music"})).To(Succeed())
|
||||||
|
|
||||||
lib, err := loadLibraryView(ctx, ds, 1)
|
lib, err := loadLibraryView(ctx, ds, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -45,7 +45,7 @@ var _ = Describe("loadLibraryView", Ordered, func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns an error when the library path uses an unregistered scheme", func() {
|
It("returns an error when the library path uses an unregistered scheme", func() {
|
||||||
Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
Expect(ds.Library().Put(ctx, &model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed())
|
||||||
_, err := loadLibraryView(ctx, ds, 2)
|
_, err := loadLibraryView(ctx, ds, 2)
|
||||||
Expect(err).To(HaveOccurred())
|
Expect(err).To(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ type processor struct {
|
||||||
// acquire resolves one queue item end to end: find an image, hash/decode/
|
// acquire resolves one queue item end to end: find an image, hash/decode/
|
||||||
// blurhash it, place its bytes, and persist the resulting state.
|
// blurhash it, place its bytes, and persist the resulting state.
|
||||||
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (out outcome, got *acquired, retryIn time.Duration) {
|
||||||
repo := p.ds.Artwork(ctx)
|
repo := p.ds.Artwork()
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
log.Debug(ctx, "Artwork: Acquisition finished", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
|
|
@ -137,7 +137,7 @@ func (p *processor) acquire(ctx context.Context, item model.ArtworkQueueItem) (o
|
||||||
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
log.Trace(ctx, "Artwork: Hashed image", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
"hash", hash, "bytes", len(data), "elapsed", time.Since(hashStart))
|
||||||
|
|
||||||
art, err := repo.GetImage(hash)
|
art, err := repo.GetImage(ctx, hash)
|
||||||
switch {
|
switch {
|
||||||
case err == nil && art.Width > 0:
|
case err == nil && art.Width > 0:
|
||||||
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
log.Debug(ctx, "Artwork: Reusing a known image, skipping decode", "kind", item.ItemKind,
|
||||||
|
|
@ -195,7 +195,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("writing image store: %w", err)
|
return nil, fmt.Errorf("writing image store: %w", err)
|
||||||
}
|
}
|
||||||
if err := repo.PutImage(art); err != nil {
|
if err := repo.PutImage(ctx, art); err != nil {
|
||||||
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
return nil, fmt.Errorf("persisting artwork image: %w", err)
|
||||||
}
|
}
|
||||||
ia := &model.ItemArtwork{
|
ia := &model.ItemArtwork{
|
||||||
|
|
@ -210,7 +210,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
Trace: traceFrom(ctx).encode(sourcePath),
|
Trace: traceFrom(ctx).encode(sourcePath),
|
||||||
}
|
}
|
||||||
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
// PutItemArtwork stamps UpdatedAt on ia, so the returned struct matches the persisted row.
|
||||||
if err := repo.PutItemArtwork(ia); err != nil {
|
if err := repo.PutItemArtwork(ctx, ia); err != nil {
|
||||||
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
return nil, fmt.Errorf("persisting item artwork state: %w", err)
|
||||||
}
|
}
|
||||||
return ia, nil
|
return ia, nil
|
||||||
|
|
@ -218,7 +218,7 @@ func (p *processor) persist(ctx context.Context, repo model.ArtworkRepository, i
|
||||||
|
|
||||||
// writeAbsent records a known-absent state: every source answered definitively "no".
|
// writeAbsent records a known-absent state: every source answered definitively "no".
|
||||||
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
func writeAbsent(ctx context.Context, repo model.ArtworkRepository, item model.ArtworkQueueItem) outcome {
|
||||||
err := repo.PutItemArtwork(&model.ItemArtwork{
|
err := repo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: item.ItemKind,
|
ItemKind: item.ItemKind,
|
||||||
ItemID: item.ItemID,
|
ItemID: item.ItemID,
|
||||||
ImageType: item.ImageType,
|
ImageType: item.ImageType,
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,14 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/cover.jpg"))
|
||||||
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
Expect(ia.RefMtime).To(BeNumerically(">", 0))
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
// Every placeholder is derived from the one shared thumbnail, so all three land together.
|
||||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
@ -156,12 +156,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al2", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
Expect(filepath.ToSlash(ia.SourcePath)).To(HaveSuffix("tests/fixtures/artist/an-album/test.mp3"))
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.BlurHash).ToNot(BeEmpty())
|
Expect(art.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
||||||
|
|
@ -179,7 +179,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al3"})
|
||||||
Expect(out).To(Equal(outcomeAbsent))
|
Expect(out).To(Equal(outcomeAbsent))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
Expect(ia.Source).To(BeEmpty())
|
Expect(ia.Source).To(BeEmpty())
|
||||||
|
|
@ -200,7 +200,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al-io"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al-io", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "an I/O fault must not be recorded as absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -225,7 +225,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra-io"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra-io", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "an unreadable upload must not be recorded as absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -269,7 +269,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
|
Expect(retryIn).To(BeZero(), "a plain failure asks for no particular delay")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -299,7 +299,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})
|
||||||
Expect(out).To(Equal(outcomeFoundStale))
|
Expect(out).To(Equal(outcomeFoundStale))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
@ -316,10 +316,10 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alU"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alU", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(art.Width).To(BeZero())
|
Expect(art.Width).To(BeZero())
|
||||||
Expect(art.BlurHash).To(BeEmpty())
|
Expect(art.BlurHash).To(BeEmpty())
|
||||||
|
|
@ -336,7 +336,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alE"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alE", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -354,7 +354,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alX"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alX", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -373,12 +373,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alext"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alext", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("external:deezerFake"))
|
Expect(ia.Source).To(Equal("external:deezerFake"))
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(ia.Hash, art.Mime)
|
rc, err := store.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -397,7 +397,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
out1, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al5"})
|
||||||
Expect(out1).To(Equal(outcomeFound))
|
Expect(out1).To(Equal(outcomeFound))
|
||||||
ia1, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
ia1, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al5", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
// A re-decode instead of a hash dedup would overwrite this sentinel.
|
||||||
|
|
@ -407,11 +407,11 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
out2, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al6"})
|
||||||
Expect(out2).To(Equal(outcomeFound))
|
Expect(out2).To(Equal(outcomeFound))
|
||||||
ia2, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
ia2, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al6", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
Expect(ia2.Hash).To(Equal(ia1.Hash))
|
||||||
|
|
||||||
reused, err := artRepo.GetImage(ia1.Hash)
|
reused, err := artRepo.GetImage(ctx, ia1.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||||
})
|
})
|
||||||
|
|
@ -437,7 +437,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
folderRepo.result = []model.Folder{{Path: "album-a", ImageFiles: []string{"cover.jpg"}}}
|
||||||
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
outN, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alA"})
|
||||||
Expect(outN).To(Equal(outcomeFound))
|
Expect(outN).To(Equal(outcomeFound))
|
||||||
iaA, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
iaA, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(iaA.Source).To(Equal("folder"))
|
Expect(iaA.Source).To(Equal("folder"))
|
||||||
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
Expect(filepath.ToSlash(iaA.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||||
|
|
@ -451,19 +451,19 @@ var _ = Describe("processor.acquire", func() {
|
||||||
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
folderRepo.result = []model.Folder{{Path: "album-b", ImageFiles: []string{"cover.jpg"}}}
|
||||||
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
outN, _, _ = proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alB"})
|
||||||
Expect(outN).To(Equal(outcomeFound))
|
Expect(outN).To(Equal(outcomeFound))
|
||||||
iaB, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
iaB, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alB", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
Expect(iaB.Hash).To(Equal(iaA.Hash))
|
||||||
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
Expect(filepath.ToSlash(iaB.SourcePath)).To(HaveSuffix("album-b/cover.jpg"))
|
||||||
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
Expect(iaB.RefMtime).To(Equal(time.Unix(2000, 0).UnixNano()))
|
||||||
|
|
||||||
iaAafter, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
iaAafter, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alA", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
Expect(filepath.ToSlash(iaAafter.SourcePath)).To(HaveSuffix("album-a/cover.jpg"))
|
||||||
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
Expect(iaAafter.RefMtime).To(Equal(time.Unix(1000, 0).UnixNano()))
|
||||||
|
|
||||||
Expect(artRepo.Data).To(HaveLen(1))
|
Expect(artRepo.Data).To(HaveLen(1))
|
||||||
reused, err := artRepo.GetImage(iaA.Hash)
|
reused, err := artRepo.GetImage(ctx, iaA.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
Expect(reused.BlurHash).To(Equal("SENTINEL"))
|
||||||
})
|
})
|
||||||
|
|
@ -483,7 +483,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "ra1"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "ra1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -504,7 +504,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "ra", ItemID: "big"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindRadioArtwork, "big", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -565,12 +565,12 @@ var _ = Describe("processor.acquire", func() {
|
||||||
|
|
||||||
hash, err := hashImage(bytes.NewReader(imgBytes))
|
hash, err := hashImage(bytes.NewReader(imgBytes))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(artRepo.PutImage(&model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
Expect(artRepo.PutImage(ctx, &model.Artwork{Hash: hash, Mime: "application/octet-stream"})).To(Succeed())
|
||||||
|
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alM"})
|
||||||
Expect(out).To(Equal(outcomeFound))
|
Expect(out).To(Equal(outcomeFound))
|
||||||
|
|
||||||
upgraded, err := artRepo.GetImage(hash)
|
upgraded, err := artRepo.GetImage(ctx, hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
Expect(upgraded.Width).To(BeNumerically(">", 0))
|
||||||
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
Expect(upgraded.BlurHash).ToNot(BeEmpty())
|
||||||
|
|
@ -590,7 +590,7 @@ var _ = Describe("processor.acquire", func() {
|
||||||
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
out, _, _ := proc.acquire(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al7"})
|
||||||
Expect(out).To(Equal(outcomeFailed))
|
Expect(out).To(Equal(outcomeFailed))
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -14,9 +14,9 @@ const pruneMinAge = time.Hour
|
||||||
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
defer func() { log.Debug(ctx, "Artwork: Prune finished", "elapsed", time.Since(start)) }()
|
||||||
repo := ds.Artwork(ctx)
|
repo := ds.Artwork()
|
||||||
|
|
||||||
purged, err := repo.PurgeDanglingItems()
|
purged, err := repo.PurgeDanglingItems(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
// Queue rows for deleted entities would otherwise retry forever (Get -> not found -> failed).
|
||||||
queuePurged, err := ds.ArtworkQueue(ctx).PurgeDangling()
|
queuePurged, err := ds.ArtworkQueue().PurgeDangling(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -35,7 +35,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
|
|
||||||
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
// Files younger than the grace window may belong to acquisitions whose rows aren't committed yet.
|
||||||
cutoff := time.Now().Add(-pruneMinAge)
|
cutoff := time.Now().Add(-pruneMinAge)
|
||||||
orphans, err := repo.PurgeOrphans(cutoff)
|
orphans, err := repo.PurgeOrphans(ctx, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ func prune(ctx context.Context, ds model.DataStore, store *ImageStore) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
// Read after the delete, so the sweep below reclaims the files of the rows just removed.
|
||||||
mimes, err := repo.GetMimeByHash()
|
mimes, err := repo.GetMimeByHash(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,18 +18,20 @@ type flakyGetArtworkRepo struct {
|
||||||
*tests.MockArtworkRepo
|
*tests.MockArtworkRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *flakyGetArtworkRepo) GetMimeByHash() (map[string]string, error) {
|
func (f *flakyGetArtworkRepo) GetMimeByHash(context.Context) (map[string]string, error) {
|
||||||
return nil, errors.New("db locked")
|
return nil, errors.New("db locked")
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ = Describe("Prune", func() {
|
var _ = Describe("Prune", func() {
|
||||||
|
var ctx context.Context
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
var store *ImageStore
|
var store *ImageStore
|
||||||
var awRepo *tests.MockArtworkRepo
|
var awRepo *tests.MockArtworkRepo
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{}
|
||||||
awRepo = ds.Artwork(context.Background()).(*tests.MockArtworkRepo)
|
awRepo = ds.Artwork().(*tests.MockArtworkRepo)
|
||||||
store = NewImageStore(GinkgoT().TempDir())
|
store = NewImageStore(GinkgoT().TempDir())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -41,9 +43,9 @@ var _ = Describe("Prune", func() {
|
||||||
}
|
}
|
||||||
|
|
||||||
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
It("purges dangling item_artwork state for gone entities, summed across kinds", func() {
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "gone-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "ar", ItemID: "live-artist", ImageType: model.ImageTypePrimary})).To(Succeed())
|
||||||
awRepo.ExistingIDs = map[string]map[string]bool{
|
awRepo.ExistingIDs = map[string]map[string]bool{
|
||||||
"al": {},
|
"al": {},
|
||||||
"ar": {"live-artist": true},
|
"ar": {"live-artist": true},
|
||||||
|
|
@ -51,17 +53,17 @@ var _ = Describe("Prune", func() {
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetItemArtwork(model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
_, err := awRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "gone-album", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "gone-artist", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = awRepo.GetItemArtwork(model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
_, err = awRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "live-artist", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("purges dangling artwork_queue rows for gone entities", func() {
|
It("purges dangling artwork_queue rows for gone entities", func() {
|
||||||
queueRepo := tests.CreateMockArtworkQueueRepo()
|
queueRepo := tests.CreateMockArtworkQueueRepo()
|
||||||
Expect(queueRepo.Enqueue(
|
Expect(queueRepo.Enqueue(ctx,
|
||||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
model.ArtworkQueueItem{ItemKind: "al", ItemID: "gone-album", ImageType: model.ImageTypePrimary},
|
||||||
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
model.ArtworkQueueItem{ItemKind: "al", ItemID: "live-album", ImageType: model.ImageTypePrimary},
|
||||||
)).To(Succeed())
|
)).To(Succeed())
|
||||||
|
|
@ -80,17 +82,17 @@ var _ = Describe("Prune", func() {
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
old := time.Now().Add(-2 * time.Hour)
|
old := time.Now().Add(-2 * time.Hour)
|
||||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, old)
|
ageArtwork(h, old)
|
||||||
|
|
||||||
kept := []byte("kept-bytes")
|
kept := []byte("kept-bytes")
|
||||||
hk, _ := hashImage(bytes.NewReader(kept))
|
hk, _ := hashImage(bytes.NewReader(kept))
|
||||||
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
Expect(store.Write(hk, "image/jpeg", bytes.NewReader(kept))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hk, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(h)
|
_, err := awRepo.GetImage(ctx, h)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = store.Open(h, "image/jpeg")
|
_, err = store.Open(h, "image/jpeg")
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
@ -103,14 +105,14 @@ var _ = Describe("Prune", func() {
|
||||||
data := []byte("reacquired-bytes")
|
data := []byte("reacquired-bytes")
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||||
Expect(awRepo.PutItemArtwork(&model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
Expect(awRepo.PutItemArtwork(ctx, &model.ItemArtwork{ItemKind: "al", ItemID: "a1",
|
||||||
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
ImageType: model.ImageTypePrimary, Hash: h, Source: "folder"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(h)
|
_, err := awRepo.GetImage(ctx, h)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(h, "image/jpeg")
|
rc, err := store.Open(h, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -122,11 +124,11 @@ var _ = Describe("Prune", func() {
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
// Reacquisition refreshed created_at, so the row is unreferenced but too young to drop.
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(h)
|
_, err := awRepo.GetImage(ctx, h)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
rc, err := store.Open(h, "image/jpeg")
|
rc, err := store.Open(h, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -137,7 +139,7 @@ var _ = Describe("Prune", func() {
|
||||||
data := []byte("racing-bytes")
|
data := []byte("racing-bytes")
|
||||||
h, _ := hashImage(bytes.NewReader(data))
|
h, _ := hashImage(bytes.NewReader(data))
|
||||||
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
Expect(store.Write(h, "image/jpeg", bytes.NewReader(data))).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
ageArtwork(h, time.Now().Add(-2*time.Hour))
|
||||||
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
// The row is orphaned, but a concurrent acquisition just touched the file's mtime.
|
||||||
|
|
||||||
|
|
@ -170,7 +172,7 @@ var _ = Describe("Prune", func() {
|
||||||
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/png"), old, old)).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(h, "image/jpeg"), old, old)).To(Succeed())
|
||||||
// The row records the current mime; the .png file is a superseded variant.
|
// The row records the current mime; the .png file is a superseded variant.
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: h, Mime: "image/jpeg"})).To(Succeed())
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -192,14 +194,14 @@ var _ = Describe("Prune", func() {
|
||||||
hb, _ := hashImage(bytes.NewReader(blocked))
|
hb, _ := hashImage(bytes.NewReader(blocked))
|
||||||
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
Expect(store.Write(hb, "image/jpeg", bytes.NewReader(blocked))).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(hb, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hb, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(hb, old)
|
ageArtwork(hb, old)
|
||||||
|
|
||||||
good := []byte("good-bytes")
|
good := []byte("good-bytes")
|
||||||
hg, _ := hashImage(bytes.NewReader(good))
|
hg, _ := hashImage(bytes.NewReader(good))
|
||||||
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
Expect(store.Write(hg, "image/jpeg", bytes.NewReader(good))).To(Succeed())
|
||||||
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
Expect(os.Chtimes(store.path(hg, "image/jpeg"), old, old)).To(Succeed())
|
||||||
Expect(awRepo.PutImage(&model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
Expect(awRepo.PutImage(ctx, &model.Artwork{Hash: hg, Mime: "image/jpeg"})).To(Succeed())
|
||||||
ageArtwork(hg, old)
|
ageArtwork(hg, old)
|
||||||
|
|
||||||
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
// A read-only shard directory makes os.Remove fail (EACCES) for hb's file only.
|
||||||
|
|
@ -210,13 +212,13 @@ var _ = Describe("Prune", func() {
|
||||||
|
|
||||||
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
Expect(prune(context.Background(), ds, store)).To(Succeed())
|
||||||
|
|
||||||
_, err := awRepo.GetImage(hg)
|
_, err := awRepo.GetImage(ctx, hg)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
_, err = store.Open(hg, "image/jpeg")
|
_, err = store.Open(hg, "image/jpeg")
|
||||||
Expect(os.IsNotExist(err)).To(BeTrue())
|
Expect(os.IsNotExist(err)).To(BeTrue())
|
||||||
|
|
||||||
// The row purge does not depend on file removal, so only the file survives.
|
// The row purge does not depend on file removal, so only the file survives.
|
||||||
_, err = awRepo.GetImage(hb)
|
_, err = awRepo.GetImage(ctx, hb)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
rc, err := store.Open(hb, "image/jpeg")
|
rc, err := store.Open(hb, "image/jpeg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ func (r *resolver) fetchExternalArtist(ctx context.Context, ar model.Artist) (io
|
||||||
|
|
||||||
// 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(ctx).Get(albumID)
|
al, err := r.ds.Album().Get(ctx, albumID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -243,7 +243,7 @@ func (r *resolver) resolveAlbum(ctx context.Context, albumID string) (resolution
|
||||||
|
|
||||||
// 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(ctx).Get(artistID)
|
ar, err := r.ds.Artist().Get(ctx, artistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -259,7 +259,7 @@ func (r *resolver) resolveArtist(ctx context.Context, artistID string) (resoluti
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only consider albums where the artist is the sole album artist.
|
// Only consider albums where the artist is the sole album artist.
|
||||||
als, err := r.ds.Album(ctx).GetAll(model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
als, err := r.ds.Album().GetAll(ctx, model.QueryOptions{Filters: persistence.SoleAlbumArtistFilter(artistID)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -328,7 +328,7 @@ const PlaylistGridSamples = 4
|
||||||
|
|
||||||
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
// resolvePlaylist tries the uploaded image, the sidecar and ExternalImageURL, then a generated grid.
|
||||||
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (resolution, error) {
|
||||||
pl, err := r.ds.Playlist(ctx).Get(playlistID)
|
pl, err := r.ds.Playlist().Get(ctx, playlistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
albumIDs, err := r.ds.Playlist(ctx).Tracks(pl.ID, false).
|
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
|
||||||
GetAlbumIDs(model.QueryOptions{Max: PlaylistGridSamples, Sort: "random()"})
|
if tracks == nil {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
@ -428,7 +431,7 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||||
|
|
||||||
// resolveRadio serves only an uploaded image; there is no fallback.
|
// resolveRadio serves only an uploaded image; there is no fallback.
|
||||||
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution, error) {
|
||||||
radio, err := r.ds.Radio(ctx).Get(radioID)
|
radio, err := r.ds.Radio().Get(ctx, radioID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -439,7 +442,7 @@ func (r *resolver) resolveRadio(ctx context.Context, radioID string) (resolution
|
||||||
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
|
// resolveMediaFile resolves a track's own embedded art only, so disabled or missing cover art
|
||||||
// is a definitive absent.
|
// is a definitive absent.
|
||||||
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
func (r *resolver) resolveMediaFile(ctx context.Context, id string) (resolution, error) {
|
||||||
mf, err := r.ds.MediaFile(ctx).Get(id)
|
mf, err := r.ds.MediaFile().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolution{}, err
|
return resolution{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -707,6 +707,16 @@ 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{}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("Uploader", func() {
|
var _ = Describe("Uploader", func() {
|
||||||
|
var ctx context.Context
|
||||||
var svc Uploader
|
var svc Uploader
|
||||||
var tmpDir string
|
var tmpDir string
|
||||||
var artRepo *tests.MockArtworkRepo
|
var artRepo *tests.MockArtworkRepo
|
||||||
var queueRepo *tests.MockArtworkQueueRepo
|
var queueRepo *tests.MockArtworkQueueRepo
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
tmpDir = GinkgoT().TempDir()
|
tmpDir = GinkgoT().TempDir()
|
||||||
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
conf.Server.DataFolder = conf.NewDir(tmpDir)
|
||||||
|
|
@ -33,7 +35,6 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
Describe("SetImage", func() {
|
Describe("SetImage", func() {
|
||||||
It("creates directory and saves image file", func() {
|
It("creates directory and saves image file", func() {
|
||||||
ctx := context.Background()
|
|
||||||
reader := strings.NewReader("fake image data")
|
reader := strings.NewReader("fake image data")
|
||||||
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
filename, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Pink Floyd", "", reader, ".jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -46,7 +47,6 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("falls back to ID-only filename when name cleans to empty", func() {
|
It("falls back to ID-only filename when name cleans to empty", func() {
|
||||||
ctx := context.Background()
|
|
||||||
reader := strings.NewReader("data")
|
reader := strings.NewReader("data")
|
||||||
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
filename, err := svc.SetImage(ctx, consts.EntityPlaylist, "pl-1", "!!!", "", reader, ".png")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -54,7 +54,6 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("removes old image when replacing", func() {
|
It("removes old image when replacing", func() {
|
||||||
ctx := context.Background()
|
|
||||||
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
oldDir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
Expect(os.MkdirAll(oldDir, 0755)).To(Succeed())
|
||||||
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
oldFile := filepath.Join(oldDir, "ar-1_old.png")
|
||||||
|
|
@ -70,15 +69,13 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("ignores missing old file without error", func() {
|
It("ignores missing old file without error", func() {
|
||||||
ctx := context.Background()
|
|
||||||
reader := strings.NewReader("data")
|
reader := strings.NewReader("data")
|
||||||
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
_, err := svc.SetImage(ctx, consts.EntityArtist, "ar-1", "Name", "/nonexistent/path.jpg", reader, ".jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
It("does not touch artwork state or the queue (that is EnqueueArtwork's job, post-Put)", func() {
|
||||||
ctx := context.Background()
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
|
||||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -87,25 +84,24 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
// SetImage only writes the file; the state row survives and nothing is queued until
|
// SetImage only writes the file; the state row survives and nothing is queued until
|
||||||
// the caller has persisted the new filename and called EnqueueArtwork.
|
// the caller has persisted the new filename and called EnqueueArtwork.
|
||||||
_, err = artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("EnqueueArtwork", func() {
|
Describe("EnqueueArtwork", func() {
|
||||||
It("clears artwork state and enqueues a Bump", func() {
|
It("clears artwork state and enqueues a Bump", func() {
|
||||||
ctx := context.Background()
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
Expect(artRepo.PutItemArtwork(&model.ItemArtwork{
|
|
||||||
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
ItemKind: "ar", ItemID: "ar-1", Hash: "oldhash", Source: "external",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
svc.EnqueueArtwork(ctx, consts.EntityArtist, "ar-1")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "ar-1", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound))
|
Expect(err).To(MatchError(model.ErrNotFound))
|
||||||
|
|
||||||
queued, err := queueRepo.DequeueBatch(1000)
|
queued, err := queueRepo.DequeueBatch(ctx, 1000)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(queued).To(ContainElement(SatisfyAll(
|
Expect(queued).To(ContainElement(SatisfyAll(
|
||||||
HaveField("ItemKind", "ar"),
|
HaveField("ItemKind", "ar"),
|
||||||
|
|
@ -116,13 +112,12 @@ var _ = Describe("Uploader", func() {
|
||||||
|
|
||||||
It("is a no-op for an unknown entity type", func() {
|
It("is a no-op for an unknown entity type", func() {
|
||||||
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
svc.EnqueueArtwork(context.Background(), "unknown", "x-1")
|
||||||
Expect(queueRepo.DequeueBatch(1000)).To(BeEmpty())
|
Expect(queueRepo.DequeueBatch(ctx, 1000)).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("RemoveImage", func() {
|
Describe("RemoveImage", func() {
|
||||||
It("removes the file at the given path", func() {
|
It("removes the file at the given path", func() {
|
||||||
ctx := context.Background()
|
|
||||||
dir := filepath.Join(tmpDir, "artwork", "artist")
|
dir := filepath.Join(tmpDir, "artwork", "artist")
|
||||||
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
Expect(os.MkdirAll(dir, 0755)).To(Succeed())
|
||||||
path := filepath.Join(dir, "ar-1_test.jpg")
|
path := filepath.Join(dir, "ar-1_test.jpg")
|
||||||
|
|
@ -134,13 +129,11 @@ var _ = Describe("Uploader", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("succeeds when file does not exist", func() {
|
It("succeeds when file does not exist", func() {
|
||||||
ctx := context.Background()
|
|
||||||
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
err := svc.RemoveImage(ctx, "/nonexistent/file.jpg")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
||||||
It("succeeds with empty path", func() {
|
It("succeeds with empty path", func() {
|
||||||
ctx := context.Background()
|
|
||||||
err := svc.RemoveImage(ctx, "")
|
err := svc.RemoveImage(ctx, "")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -45,7 +47,7 @@ type Worker struct {
|
||||||
broker events.Broker
|
broker events.Broker
|
||||||
pruneMu sync.RWMutex
|
pruneMu sync.RWMutex
|
||||||
pools []*drainPool
|
pools []*drainPool
|
||||||
runCtx context.Context
|
runCtx context.Context //nolint:containedctx // worker lifecycle ctx, set at Run
|
||||||
paused func() bool
|
paused func() bool
|
||||||
|
|
||||||
gatesMu sync.Mutex
|
gatesMu sync.Mutex
|
||||||
|
|
@ -155,7 +157,7 @@ func (w *Worker) drain(ctx context.Context, concurrency int, kinds ...string) (i
|
||||||
}
|
}
|
||||||
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
// Dequeue well past the pool size so a slow external lookup never idles the other slots.
|
||||||
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
// DequeueBatch does not mark rows taken, so this is one query per pass, not per slot.
|
||||||
items, err := w.proc.ds.ArtworkQueue(ctx).DequeueBatch(max(16, 4*concurrency), kinds...)
|
items, err := w.proc.ds.ArtworkQueue().DequeueBatch(ctx, max(16, 4*concurrency), kinds...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
@ -244,14 +246,14 @@ 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.proc.acquire(ctx, item)
|
out, got, retryIn := w.safeAcquire(ctx, item)
|
||||||
|
|
||||||
queue := w.proc.ds.ArtworkQueue(ctx)
|
queue := w.proc.ds.ArtworkQueue()
|
||||||
switch out {
|
switch out {
|
||||||
case outcomeFound, outcomeAbsent:
|
case outcomeFound, outcomeAbsent:
|
||||||
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
// A scan that re-enqueued this row mid-flight reset its retry_at, so the row survives
|
||||||
// here and the next drain re-resolves it.
|
// here and the next drain re-resolves it.
|
||||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not delete processed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
case outcomeFoundStale, outcomeFailed:
|
case outcomeFoundStale, outcomeFailed:
|
||||||
|
|
@ -260,7 +262,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
if retryAt.Before(item.EnqueuedAt.Add(giveUpAfter)) {
|
||||||
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
// A mid-flight re-enqueue reset retry_at; stale backoff must not stomp its
|
||||||
// fresh, immediate eligibility.
|
// fresh, immediate eligibility.
|
||||||
if err := queue.MarkFailedIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
if err := queue.MarkFailedIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt, retryAt, encoded); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not reschedule failed queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
log.Debug(ctx, "Artwork: Rescheduled item", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
|
|
@ -271,7 +273,7 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
// Art already being served is kept: exhaustion means unreachable, not removed.
|
// Art already being served is kept: exhaustion means unreachable, not removed.
|
||||||
settled := "kept previous state"
|
settled := "kept previous state"
|
||||||
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
if out == outcomeFailed && settlesAbsentOnGiveUp(item.ItemKind) && !w.hasResolvedArtwork(ctx, item) {
|
||||||
writeAbsent(ctx, w.proc.ds.Artwork(ctx), item)
|
writeAbsent(ctx, w.proc.ds.Artwork(), item)
|
||||||
settled = "recorded absent"
|
settled = "recorded absent"
|
||||||
}
|
}
|
||||||
// The queue row is about to go, taking the only record of the failure with it. This write is
|
// The queue row is about to go, taking the only record of the failure with it. This write is
|
||||||
|
|
@ -279,13 +281,27 @@ func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outc
|
||||||
w.recordGiveUp(ctx, item, encoded)
|
w.recordGiveUp(ctx, item, encoded)
|
||||||
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
log.Info(ctx, "Artwork: Retry budget exhausted, giving up", "kind", item.ItemKind, "id", item.ItemID,
|
||||||
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
"outcome", out, "attempts", item.Attempts+1, "budget", giveUpAfter, "settled", settled)
|
||||||
if err := queue.DeleteIfUnchanged(item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
if err := queue.DeleteIfUnchanged(ctx, item.ItemKind, item.ItemID, item.ImageType, item.RetryAt); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not remove exhausted queue item", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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) {
|
||||||
|
|
@ -293,7 +309,7 @@ func (w *Worker) recordGiveUp(ctx context.Context, item model.ArtworkQueueItem,
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := w.proc.ds.Artwork(ctx).PutLastFailure(kind, item.ItemID, item.ImageType, trace); err != nil {
|
if err := w.proc.ds.Artwork().PutLastFailure(ctx, kind, item.ItemID, item.ImageType, trace); err != nil {
|
||||||
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
log.Warn(ctx, "Artwork: Could not record the last failure", "kind", item.ItemKind, "id", item.ItemID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -303,7 +319,7 @@ func (w *Worker) hasResolvedArtwork(ctx context.Context, item model.ArtworkQueue
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
ia, err := w.proc.ds.Artwork(ctx).GetItemArtwork(kind, item.ItemID, item.ImageType)
|
ia, err := w.proc.ds.Artwork().GetItemArtwork(ctx, kind, item.ItemID, item.ImageType)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,12 @@ import (
|
||||||
const soakCycles = 2200
|
const soakCycles = 2200
|
||||||
|
|
||||||
var _ = Describe("Worker soak", func() {
|
var _ = Describe("Worker soak", func() {
|
||||||
|
var ctx context.Context
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
|
})
|
||||||
|
|
||||||
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
It("does not leak goroutines, heap, or fds over many acquisition cycles", func() {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
Skip("skipping soak test in short mode")
|
Skip("skipping soak test in short mode")
|
||||||
|
|
@ -100,9 +106,9 @@ var _ = Describe("Worker soak", func() {
|
||||||
// Read-back exercises the surfaces a caller would use after acquisition.
|
// Read-back exercises the surfaces a caller would use after acquisition.
|
||||||
if out == outcomeFound {
|
if out == outcomeFound {
|
||||||
kind, _ := model.ParseKind(it.ItemKind)
|
kind, _ := model.ParseKind(it.ItemKind)
|
||||||
ia, err := artRepo.GetItemArtwork(kind, it.ItemID, model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, kind, it.ItemID, model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetItemArtwork", i)
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
Expect(err).ToNot(HaveOccurred(), "cycle %d: GetImage", i)
|
||||||
rc, err := store.Open(ia.Hash, art.Mime)
|
rc, err := store.Open(ia.Hash, art.Mime)
|
||||||
switch {
|
switch {
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,8 @@ type reenqueueOnDequeue struct {
|
||||||
done bool
|
done bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *reenqueueOnDequeue) DequeueBatch(n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
func (r *reenqueueOnDequeue) DequeueBatch(ctx context.Context, n int, kinds ...string) ([]model.ArtworkQueueItem, error) {
|
||||||
items, err := r.MockArtworkQueueRepo.DequeueBatch(n, kinds...)
|
items, err := r.MockArtworkQueueRepo.DequeueBatch(ctx, n, kinds...)
|
||||||
if !r.done && len(items) > 0 {
|
if !r.done && len(items) > 0 {
|
||||||
r.done = true
|
r.done = true
|
||||||
for k, it := range r.Data {
|
for k, it := range r.Data {
|
||||||
|
|
@ -124,18 +124,39 @@ type visibilityPlaylistDS struct {
|
||||||
tracks model.PlaylistTrackRepository
|
tracks model.PlaylistTrackRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *visibilityPlaylistDS) Playlist(ctx context.Context) model.PlaylistRepository {
|
func (v *visibilityPlaylistDS) Playlist() model.PlaylistRepository {
|
||||||
repo := tests.CreateMockPlaylistRepo()
|
repo := tests.CreateMockPlaylistRepo()
|
||||||
repo.TracksRepo = v.tracks
|
repo.TracksRepo = v.tracks
|
||||||
if u, ok := request.UserFrom(ctx); ok && u.IsAdmin {
|
|
||||||
repo.SetData(model.Playlists{v.private})
|
repo.SetData(model.Playlists{v.private})
|
||||||
|
return &visibilityPlaylistRepo{MockPlaylistRepo: repo}
|
||||||
}
|
}
|
||||||
return repo
|
|
||||||
|
type visibilityPlaylistRepo struct {
|
||||||
|
*tests.MockPlaylistRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Playlist, error) {
|
||||||
|
if u, ok := request.UserFrom(ctx); !ok || !u.IsAdmin {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
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(&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())
|
||||||
return repo
|
return repo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -157,8 +178,8 @@ var _ = Describe("Worker", func() {
|
||||||
)
|
)
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
ctx = GinkgoT().Context()
|
||||||
DeferCleanup(configtest.SetupConfig())
|
DeferCleanup(configtest.SetupConfig())
|
||||||
ctx = context.Background()
|
|
||||||
var err error
|
var err error
|
||||||
repoRoot, err = os.Getwd()
|
repoRoot, err = os.Getwd()
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -200,7 +221,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -208,11 +229,11 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
|
||||||
count, err := queueRepo.Count()
|
count, err := queueRepo.Count(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
Expect(count).To(BeZero(), "a found item must be deleted from the queue")
|
||||||
})
|
})
|
||||||
|
|
@ -223,7 +244,7 @@ var _ = Describe("Worker", func() {
|
||||||
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},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
ItemKind: "mf", ItemID: "mf1", Priority: model.ArtworkPriorityBump,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -231,12 +252,12 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mf1", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("embedded"))
|
Expect(ia.Source).To(Equal("embedded"))
|
||||||
Expect(ia.Hash).ToNot(BeEmpty())
|
Expect(ia.Hash).ToNot(BeEmpty())
|
||||||
|
|
||||||
art, err := artRepo.GetImage(ia.Hash)
|
art, err := artRepo.GetImage(ctx, ia.Hash)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
r, err := store.Open(ia.Hash, art.Mime)
|
r, err := store.Open(ia.Hash, art.Mime)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -245,7 +266,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
Expect(data).ToNot(BeEmpty(), "embedded bytes must be written to the store")
|
||||||
|
|
||||||
count, err := queueRepo.Count()
|
count, err := queueRepo.Count(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(BeZero())
|
Expect(count).To(BeZero())
|
||||||
})
|
})
|
||||||
|
|
@ -254,7 +275,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.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(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al4"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -265,17 +286,47 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(it.Attempts).To(Equal(1))
|
Expect(it.Attempts).To(Equal(1))
|
||||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
|
||||||
_, err = artRepo.GetItemArtwork(model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al4", model.ImageTypePrimary)
|
||||||
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.Server.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
|
||||||
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
|
imageAgents(&fakeImageAgent{name: "throttledAgent", err: &agents.RetryLaterError{RetryIn: askedFor}})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -296,7 +347,7 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "alstale", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
imageAgents(&fakeImageAgent{name: "failAgent", err: errors.New("agent timed out")})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alstale"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -307,7 +358,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(it.Attempts).To(Equal(1))
|
Expect(it.Attempts).To(Equal(1))
|
||||||
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alstale", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
Expect(ia.Source).To(Equal("folder"), "the fallback art is served meanwhile")
|
||||||
|
|
||||||
|
|
@ -327,7 +378,7 @@ var _ = Describe("Worker", func() {
|
||||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||||
ds.MockedArtworkQueue = racing
|
ds.MockedArtworkQueue = racing
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al7", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -337,7 +388,7 @@ var _ = Describe("Worker", func() {
|
||||||
|
|
||||||
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
// The concurrent re-enqueue changed retry_at, so the found-path delete was a no-op.
|
||||||
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
Expect(findQueued(queueRepo, "al", "al7")).ToNot(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al7", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Source).To(Equal("folder"))
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
})
|
})
|
||||||
|
|
@ -349,7 +400,7 @@ var _ = Describe("Worker", func() {
|
||||||
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
racing := &reenqueueOnDequeue{MockArtworkQueueRepo: queueRepo}
|
||||||
ds.MockedArtworkQueue = racing
|
ds.MockedArtworkQueue = racing
|
||||||
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(ds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al8"})).To(Succeed())
|
||||||
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
dequeued := findQueued(queueRepo, "al", "al8").RetryAt
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -368,7 +419,7 @@ var _ = Describe("Worker", func() {
|
||||||
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)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al9"})).To(Succeed())
|
||||||
// Age the row past the retry budget.
|
// Age the row past the retry budget.
|
||||||
expireQueued(queueRepo, "al9")
|
expireQueued(queueRepo, "al9")
|
||||||
|
|
||||||
|
|
@ -377,7 +428,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al9")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al9", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -385,13 +436,13 @@ 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.Server.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(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
ItemKind: "al", ItemID: "al10", ImageType: model.ImageTypePrimary,
|
||||||
Hash: "cafebabe", Source: "external:lastfm",
|
Hash: "cafebabe", Source: "external:lastfm",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
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)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al10"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al10")
|
expireQueued(queueRepo, "al10")
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -399,7 +450,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al10")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al10", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
Expect(ia.Hash).To(Equal("cafebabe"), "a persistent outage must not discard served art")
|
||||||
})
|
})
|
||||||
|
|
@ -409,7 +460,7 @@ var _ = Describe("Worker", func() {
|
||||||
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)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al11"})).To(Succeed())
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -430,13 +481,13 @@ var _ = Describe("Worker", func() {
|
||||||
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)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al13"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al13")
|
expireQueued(queueRepo, "al13")
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al13", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
Expect(err).ToNot(HaveOccurred(), "settling absent must create the row the failure is written to")
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty())
|
||||||
|
|
@ -445,20 +496,20 @@ 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.Server.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(&model.ItemArtwork{
|
Expect(artRepo.PutItemArtwork(ctx, &model.ItemArtwork{
|
||||||
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
ItemKind: "al", ItemID: "al12", ImageType: model.ImageTypePrimary,
|
||||||
Hash: "cafebabe", Source: "external:lastfm",
|
Hash: "cafebabe", Source: "external:lastfm",
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
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)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al12"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "al12")
|
expireQueued(queueRepo, "al12")
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
Expect(findQueued(queueRepo, "al", "al12")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al12", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
Expect(DecodeTrace(ia.LastFailure, "")).ToNot(BeEmpty(),
|
||||||
"the queue row is gone, so this is the only remaining record of the failure")
|
"the queue row is gone, so this is the only remaining record of the failure")
|
||||||
|
|
@ -473,7 +524,7 @@ var _ = Describe("Worker", func() {
|
||||||
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},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "mf", ItemID: "mfX"})).To(Succeed())
|
||||||
expireQueued(queueRepo, "mfX")
|
expireQueued(queueRepo, "mfX")
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
|
|
@ -481,7 +532,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
Expect(findQueued(queueRepo, "mf", "mfX")).To(BeNil(), "the row must stop retrying")
|
||||||
_, err = artRepo.GetItemArtwork(model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
_, err = artRepo.GetItemArtwork(ctx, model.KindMediaFileArtwork, "mfX", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound),
|
Expect(err).To(MatchError(model.ErrNotFound),
|
||||||
"no row leaves the track unresolved, so a later view can still recover it")
|
"no row leaves the track unresolved, so a later view can still recover it")
|
||||||
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
// Known gap: with no row and no absent settle, there is nowhere to keep the failure.
|
||||||
|
|
@ -496,14 +547,14 @@ var _ = Describe("Worker", func() {
|
||||||
tracks: &tests.MockPlaylistTrackRepo{},
|
tracks: &tests.MockPlaylistTrackRepo{},
|
||||||
}
|
}
|
||||||
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
w = NewWorker(vds, store, ag, ffm, broker, imgCache)
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "pl", ItemID: "plPriv"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 1)
|
n, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(n).To(Equal(1))
|
Expect(n).To(Equal(1))
|
||||||
|
|
||||||
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
Expect(findQueued(queueRepo, "pl", "plPriv")).To(BeNil())
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindPlaylistArtwork, "plPriv", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty())
|
Expect(ia.Hash).To(BeEmpty())
|
||||||
})
|
})
|
||||||
|
|
@ -523,9 +574,9 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album 1", FolderIDs: []string{"f1"}},
|
||||||
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
{ID: "al2", Name: "Album 2", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "al2", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1", Priority: model.ArtworkPriorityScan})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 3)
|
n, err := w.drain(ctx, 3)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -572,7 +623,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.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(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())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -582,7 +633,7 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
Expect(evts).To(HaveLen(1), "a removed cover must live-refresh clients so they drop it")
|
||||||
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
Expect(evts[0].(*events.RefreshResource).Data(evts[0])).To(ContainSubstring("al3"))
|
||||||
|
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "al3", model.ImageTypePrimary)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
Expect(ia.Hash).To(BeEmpty(), "the outcome was absent, not found")
|
||||||
})
|
})
|
||||||
|
|
@ -591,7 +642,7 @@ var _ = Describe("Worker", func() {
|
||||||
conf.Server.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(model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alx"})).To(Succeed())
|
||||||
|
|
||||||
n, err := w.drain(ctx, 2)
|
n, err := w.drain(ctx, 2)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
@ -725,7 +776,7 @@ var _ = Describe("Worker", func() {
|
||||||
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "alpc", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
conf.Server.UICoverArtSize = 300
|
conf.Server.UICoverArtSize = 300
|
||||||
Expect(queueRepo.Enqueue(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())
|
||||||
})
|
})
|
||||||
|
|
@ -813,11 +864,11 @@ var _ = Describe("Worker", func() {
|
||||||
|
|
||||||
// Artists first, exactly as Backfill orders them.
|
// Artists first, exactly as Backfill orders them.
|
||||||
for _, a := range artists {
|
for _, a := range artists {
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
ItemKind: "ar", ItemID: a.ID, Priority: model.ArtworkPriorityBackfill,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
ItemKind: "al", ItemID: "alx", Priority: model.ArtworkPriorityBackfill,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
|
|
||||||
|
|
@ -832,12 +883,12 @@ var _ = Describe("Worker", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
Eventually(func() bool {
|
Eventually(func() bool {
|
||||||
ia, err := artRepo.GetItemArtwork(model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alx", model.ImageTypePrimary)
|
||||||
return err == nil && ia.Hash != ""
|
return err == nil && ia.Hash != ""
|
||||||
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
}, 5*time.Second, 50*time.Millisecond).Should(BeTrue(),
|
||||||
"a blocked external pool must not hold up local artwork")
|
"a blocked external pool must not hold up local artwork")
|
||||||
|
|
||||||
_, err := artRepo.GetItemArtwork(model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
_, err := artRepo.GetItemArtwork(ctx, model.KindArtistArtwork, "arx0", model.ImageTypePrimary)
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
Expect(err).To(MatchError(model.ErrNotFound), "artists are still blocked, as intended")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -849,7 +900,7 @@ var _ = Describe("Worker", func() {
|
||||||
for i := range 8 {
|
for i := range 8 {
|
||||||
id := fmt.Sprintf("alc%d", i)
|
id := fmt.Sprintf("alc%d", i)
|
||||||
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
albums = append(albums, model.Album{ID: id, Name: "Album"})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
@ -875,21 +926,21 @@ var _ = Describe("Worker", func() {
|
||||||
for i := range 8 {
|
for i := range 8 {
|
||||||
id := fmt.Sprintf("alp%d", i)
|
id := fmt.Sprintf("alp%d", i)
|
||||||
albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}})
|
albums = append(albums, model.Album{ID: id, Name: "Album", FolderIDs: []string{"f1"}})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: id, Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(albums)
|
||||||
// Pauses as soon as the first item has left the queue.
|
// Pauses as soon as the first item has left the queue.
|
||||||
w.PauseWhile(func() bool {
|
w.PauseWhile(func() bool {
|
||||||
n, _ := queueRepo.Count()
|
n, _ := queueRepo.Count(ctx)
|
||||||
return n < 8
|
return n < 8
|
||||||
})
|
})
|
||||||
|
|
||||||
_, err := w.drain(ctx, 1)
|
_, err := w.drain(ctx, 1)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
count, err := queueRepo.Count()
|
count, err := queueRepo.Count(ctx)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
|
Expect(count).To(Equal(int64(7)), "only the item dispatched before the pause may leave the queue")
|
||||||
})
|
})
|
||||||
|
|
@ -897,7 +948,7 @@ var _ = Describe("Worker", func() {
|
||||||
It("dequeues past the worker pool so one drain covers many items", func() {
|
It("dequeues past the worker pool so one drain covers many items", func() {
|
||||||
for i := range 16 {
|
for i := range 16 {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{{ID: fmt.Sprintf("alb%d", i), Name: "Album"}})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: fmt.Sprintf("alb%d", i), Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
}
|
}
|
||||||
|
|
@ -932,7 +983,7 @@ var _ = Describe("Worker", func() {
|
||||||
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
ds.MockedAlbum.(*tests.MockAlbumRepo).SetData(model.Albums{
|
||||||
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
{ID: "al1", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
})
|
})
|
||||||
Expect(queueRepo.Enqueue(model.ArtworkQueueItem{
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{
|
||||||
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
ItemKind: "al", ItemID: "al1", Priority: model.ArtworkPriorityScan,
|
||||||
})).To(Succeed())
|
})).To(Succeed())
|
||||||
w.PauseWhile(func() bool { return true })
|
w.PauseWhile(func() bool { return true })
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ func Init(ds model.DataStore) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
func loadOrCreateSecret(ctx context.Context, ds model.DataStore, key string) string {
|
||||||
secret, err := ds.Property(ctx).Get(key)
|
secret, err := ds.Property().Get(ctx, key)
|
||||||
if err != nil || secret == "" {
|
if err != nil || secret == "" {
|
||||||
log.Info(ctx, "Creating new JWT secret", "key", key)
|
log.Info(ctx, "Creating new JWT secret", "key", key)
|
||||||
return createNewSecret(ctx, ds, key)
|
return createNewSecret(ctx, ds, key)
|
||||||
|
|
@ -154,9 +154,9 @@ func CheckClaims(c Claims, usr model.User, audience string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
|
||||||
u, err := ds.User(ctx).FindFirstAdmin()
|
u, err := ds.User().FindFirstAdmin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c, err := ds.User(ctx).CountAll()
|
c, err := ds.User().CountAll(ctx)
|
||||||
if c == 0 && err == nil {
|
if c == 0 && err == nil {
|
||||||
log.Debug(ctx, "No admin user yet!", err)
|
log.Debug(ctx, "No admin user yet!", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -176,7 +176,7 @@ func createNewSecret(ctx context.Context, ds model.DataStore, key string) string
|
||||||
log.Error(ctx, "Could not encrypt JWT secret", err)
|
log.Error(ctx, "Could not encrypt JWT secret", err)
|
||||||
return secret
|
return secret
|
||||||
}
|
}
|
||||||
if err := ds.Property(ctx).Put(key, encSecret); err != nil {
|
if err := ds.Property().Put(ctx, key, encSecret); err != nil {
|
||||||
log.Error(ctx, "Could not save JWT secret in DB", err)
|
log.Error(ctx, "Could not save JWT secret in DB", err)
|
||||||
}
|
}
|
||||||
return secret
|
return secret
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ func userName(ctx context.Context) string {
|
||||||
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
// BFR We should only access files through the `storage.Storage` interface. This will require changing how
|
||||||
// TagLib and ffmpeg access files
|
// TagLib and ffmpeg access files
|
||||||
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
var AbsolutePath = func(ctx context.Context, ds model.DataStore, libId int, path string) string {
|
||||||
libPath, err := ds.Library(ctx).GetPath(libId)
|
libPath, err := ds.Library().GetPath(ctx, libId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
|
||||||
18
core/external/extdata_helper_test.go
vendored
18
core/external/extdata_helper_test.go
vendored
|
|
@ -31,7 +31,7 @@ func (m *mockArtistRepo) SetData(artists model.Artists) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.ArtistRepository.
|
// Get implements model.ArtistRepository.
|
||||||
func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
func (m *mockArtistRepo) Get(_ context.Context, id string) (*model.Artist, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -40,7 +40,7 @@ func (m *mockArtistRepo) Get(id string) (*model.Artist, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.ArtistRepository.
|
// GetAll implements model.ArtistRepository.
|
||||||
func (m *mockArtistRepo) GetAll(options ...model.QueryOptions) (model.Artists, error) {
|
func (m *mockArtistRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Artists, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -85,7 +85,7 @@ func (m *mockMediaFileRepo) SetData(mediaFiles model.MediaFiles) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.MediaFileRepository.
|
// Get implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
func (m *mockMediaFileRepo) Get(ctx context.Context, id string) (*model.MediaFile, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -94,12 +94,12 @@ func (m *mockMediaFileRepo) Get(id string) (*model.MediaFile, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllByTags implements model.MediaFileRepository.
|
// GetAllByTags implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetAllByTags(_ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAllByTags(ctx context.Context, _ model.TagName, _ []string, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
return m.GetAll(options...)
|
return m.GetAll(ctx, options...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.MediaFileRepository.
|
// GetAll implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetAll(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -112,7 +112,7 @@ func (m *mockMediaFileRepo) GetAll(options ...model.QueryOptions) (model.MediaFi
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRandom implements model.MediaFileRepository.
|
// GetRandom implements model.MediaFileRepository.
|
||||||
func (m *mockMediaFileRepo) GetRandom(options ...model.QueryOptions) (model.MediaFiles, error) {
|
func (m *mockMediaFileRepo) GetRandom(ctx context.Context, options ...model.QueryOptions) (model.MediaFiles, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
@ -156,7 +156,7 @@ func newMockAlbumRepo() *mockAlbumRepo {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get implements model.AlbumRepository.
|
// Get implements model.AlbumRepository.
|
||||||
func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
func (m *mockAlbumRepo) Get(_ context.Context, id string) (*model.Album, error) {
|
||||||
args := m.Called(id)
|
args := m.Called(id)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
|
|
@ -165,7 +165,7 @@ func (m *mockAlbumRepo) Get(id string) (*model.Album, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAll implements model.AlbumRepository.
|
// GetAll implements model.AlbumRepository.
|
||||||
func (m *mockAlbumRepo) GetAll(options ...model.QueryOptions) (model.Albums, error) {
|
func (m *mockAlbumRepo) GetAll(_ context.Context, options ...model.QueryOptions) (model.Albums, error) {
|
||||||
argsSlice := make([]any, len(options))
|
argsSlice := make([]any, len(options))
|
||||||
for i, v := range options {
|
for i, v := range options {
|
||||||
argsSlice[i] = v
|
argsSlice[i] = v
|
||||||
|
|
|
||||||
16
core/external/provider.go
vendored
16
core/external/provider.go
vendored
|
|
@ -182,7 +182,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
|
err = e.ds.Album().UpdateExternalInfo(ctx, &album.Album)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
|
||||||
"elapsed", time.Since(start), err)
|
"elapsed", time.Since(start), err)
|
||||||
|
|
@ -285,7 +285,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
|
||||||
if !throttled {
|
if !throttled {
|
||||||
artist.ExternalInfoUpdatedAt = new(time.Now())
|
artist.ExternalInfoUpdatedAt = new(time.Now())
|
||||||
}
|
}
|
||||||
err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist)
|
err := e.ds.Artist().UpdateExternalInfo(ctx, &artist.Artist)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
|
||||||
"elapsed", time.Since(start), err)
|
"elapsed", time.Since(start), err)
|
||||||
|
|
@ -548,7 +548,7 @@ func (e *provider) loadArtistsByID(ctx context.Context, similar []agents.Artist)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.id": ids},
|
Filters: squirrel.Eq{"artist.id": ids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -577,7 +577,7 @@ func (e *provider) loadArtistsByMBID(ctx context.Context, similar []agents.Artis
|
||||||
if len(mbids) == 0 {
|
if len(mbids) == 0 {
|
||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
Filters: squirrel.Eq{"mbz_artist_id": mbids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -612,7 +612,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
||||||
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
clauses := slice.Map(names, func(name string) squirrel.Sqlizer {
|
||||||
return squirrel.Like{"artist.name": name}
|
return squirrel.Like{"artist.name": name}
|
||||||
})
|
})
|
||||||
res, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
res, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Or(clauses),
|
Filters: squirrel.Or(clauses),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -628,7 +628,7 @@ func (e *provider) loadArtistsByName(ctx context.Context, similar []agents.Artis
|
||||||
|
|
||||||
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxArtist, error) {
|
||||||
if id != "" {
|
if id != "" {
|
||||||
artist, err := e.ds.Artist(ctx).Get(id)
|
artist, err := e.ds.Artist().Get(ctx, id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return &auxArtist{Artist: *artist}, nil
|
return &auxArtist{Artist: *artist}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -644,7 +644,7 @@ func (e *provider) findArtist(ctx context.Context, artistName, id string) (*auxA
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
artists, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
artists, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Like{"artist.name": artistName},
|
Filters: squirrel.Like{"artist.name": artistName},
|
||||||
Max: 1,
|
Max: 1,
|
||||||
})
|
})
|
||||||
|
|
@ -666,7 +666,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
|
||||||
ids = append(ids, sa.ID)
|
ids = append(ids, sa.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
similar, err := e.ds.Artist(ctx).GetAll(model.QueryOptions{
|
similar, err := e.ds.Artist().GetAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Eq{"artist.id": ids},
|
Filters: squirrel.Eq{"artist.id": ids},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
8
core/external/provider_refreshinfo_test.go
vendored
8
core/external/provider_refreshinfo_test.go
vendored
|
|
@ -71,8 +71,8 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
ag = new(mockAgents)
|
ag = new(mockAgents)
|
||||||
broker = &fakeBroker{}
|
broker = &fakeBroker{}
|
||||||
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
p = external.NewProvider(ds, ag, matcher.New(ds), broker)
|
||||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
|
||||||
mockAlbumRepo = ds.Album(ctx).(*tests.MockAlbumRepo)
|
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("repopulates an artist even when its info is fresh", func() {
|
It("repopulates an artist even when its info is fresh", func() {
|
||||||
|
|
@ -84,7 +84,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
|
|
||||||
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
|
Expect(p.RefreshInfo(ctx, model.KindArtistArtwork, "ar-1")).To(Succeed())
|
||||||
|
|
||||||
saved, err := mockArtistRepo.Get("ar-1")
|
saved, err := mockArtistRepo.Get(ctx, "ar-1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(saved.Biography).To(Equal("Fresh Bio"))
|
Expect(saved.Biography).To(Equal("Fresh Bio"))
|
||||||
})
|
})
|
||||||
|
|
@ -99,7 +99,7 @@ var _ = Describe("Provider - RefreshInfo", func() {
|
||||||
|
|
||||||
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
|
Expect(p.RefreshInfo(ctx, model.KindAlbumArtwork, "al-1")).To(Succeed())
|
||||||
|
|
||||||
saved, err := mockAlbumRepo.Get("al-1")
|
saved, err := mockAlbumRepo.Get(ctx, "al-1")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(saved.Description).To(Equal("Fresh Notes"))
|
Expect(saved.Description).To(Equal("Fresh Notes"))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
8
core/external/provider_similarsongs.go
vendored
8
core/external/provider_similarsongs.go
vendored
|
|
@ -37,7 +37,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode
|
||||||
if !errors.Is(err, model.ErrNotFound) {
|
if !errors.Is(err, model.ErrNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
genre, err := e.ds.Genre(ctx).Get(id)
|
genre, err := e.ds.Genre().Get(ctx, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -178,13 +178,13 @@ func (e *provider) seedMix(ctx context.Context, count int, sample func() (model.
|
||||||
func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) {
|
func (e *provider) samplePlaylistTracks(ctx context.Context, playlistID string, n int) (model.MediaFiles, error) {
|
||||||
// Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would
|
// Refresh: a smart playlist materializes no tracks until it is evaluated, so skipping it would
|
||||||
// mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay.
|
// mix an empty seed set. It is a no-op for regular playlists and inside the refresh delay.
|
||||||
repo := e.ds.Playlist(ctx).Tracks(playlistID, true)
|
repo := e.ds.Playlist().Tracks(ctx, playlistID, true)
|
||||||
if repo == nil {
|
if repo == nil {
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
// A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated
|
// A playlist can hold the same file at several positions, so over-fetch and dedup: a repeated
|
||||||
// seed wastes an agent call and can reach the mix twice through the seed fallback.
|
// seed wastes an agent call and can reach the mix twice through the seed fallback.
|
||||||
tracks, err := repo.GetAll(model.QueryOptions{
|
tracks, err := repo.GetAll(ctx, model.QueryOptions{
|
||||||
Sort: "random",
|
Sort: "random",
|
||||||
Max: n * 4,
|
Max: n * 4,
|
||||||
Filters: squirrel.Eq{"missing": false},
|
Filters: squirrel.Eq{"missing": false},
|
||||||
|
|
@ -225,7 +225,7 @@ func (e *provider) sampleGenreTracks(ctx context.Context, genre *model.Genre, n
|
||||||
// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so
|
// sampleTracks returns up to n random present tracks. Seeds can end up in the mix verbatim, so
|
||||||
// missing files would surface as unplayable entries.
|
// missing files would surface as unplayable entries.
|
||||||
func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) {
|
func (e *provider) sampleTracks(ctx context.Context, filter squirrel.Sqlizer, n int) (model.MediaFiles, error) {
|
||||||
return e.ds.MediaFile(ctx).GetRandom(model.QueryOptions{
|
return e.ds.MediaFile().GetRandom(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.And{filter, squirrel.Eq{"missing": false}},
|
Filters: squirrel.And{filter, squirrel.Eq{"missing": false}},
|
||||||
Max: n,
|
Max: n,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() {
|
||||||
ds = new(tests.MockDataStore)
|
ds = new(tests.MockDataStore)
|
||||||
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(ctx).(*tests.MockAlbumRepo)
|
mockAlbumRepo = ds.Album().(*tests.MockAlbumRepo)
|
||||||
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
conf.Server.DevAlbumInfoTimeToLive = 1 * time.Hour
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() {
|
||||||
ds = new(tests.MockDataStore)
|
ds = new(tests.MockDataStore)
|
||||||
ag = new(mockAgents)
|
ag = new(mockAgents)
|
||||||
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
|
p = external.NewProvider(ds, ag, matcher.New(ds), &fakeBroker{})
|
||||||
mockArtistRepo = ds.Artist(ctx).(*tests.MockArtistRepo)
|
mockArtistRepo = ds.Artist().(*tests.MockArtistRepo)
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns error when artist is not found", func() {
|
It("returns error when artist is not found", func() {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -49,7 +49,6 @@ type FFmpeg interface {
|
||||||
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
Transcode(ctx context.Context, opts TranscodeOptions) (io.ReadCloser, error)
|
||||||
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
ExtractImage(ctx context.Context, path string) (io.ReadCloser, error)
|
||||||
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
|
ConvertAnimatedImage(ctx context.Context, reader io.Reader, maxSize int, quality int) (io.ReadCloser, error)
|
||||||
Probe(ctx context.Context, files []string) (string, error)
|
|
||||||
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error)
|
||||||
CmdPath() (string, error)
|
CmdPath() (string, error)
|
||||||
IsAvailable() bool
|
IsAvailable() bool
|
||||||
|
|
@ -68,7 +67,6 @@ var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder
|
||||||
|
|
||||||
const (
|
const (
|
||||||
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -"
|
||||||
probeCmd = "ffmpeg %s -f ffmetadata"
|
|
||||||
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
|
probeAudioStreamCmd = "ffprobe -v error -select_streams a:0 -print_format json -show_streams -show_format %s"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -91,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) {
|
||||||
|
|
@ -149,17 +147,6 @@ func fileExists(path string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ffmpeg) Probe(ctx context.Context, files []string) (string, error) {
|
|
||||||
if _, err := ffmpegCmd(); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
args := createProbeCommand(probeCmd, files)
|
|
||||||
log.Trace(ctx, "Executing ffmpeg command", "args", args)
|
|
||||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...) // #nosec
|
|
||||||
output, _ := cmd.CombinedOutput()
|
|
||||||
return string(output), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
|
func (e *ffmpeg) ProbeAudioStream(ctx context.Context, filePath string) (*AudioProbeResult, error) {
|
||||||
if _, err := ffmpegCmd(); err != nil {
|
if _, err := ffmpegCmd(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -593,20 +580,6 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string {
|
||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
func createProbeCommand(cmd string, inputs []string) []string {
|
|
||||||
var args []string
|
|
||||||
for _, s := range fixCmd(cmd) {
|
|
||||||
if s == "%s" {
|
|
||||||
for _, inp := range inputs {
|
|
||||||
args = append(args, "-i", inp)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
args = append(args, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return args
|
|
||||||
}
|
|
||||||
|
|
||||||
func fixCmd(cmd string) []string {
|
func fixCmd(cmd string) []string {
|
||||||
split := strings.Fields(cmd)
|
split := strings.Fields(cmd)
|
||||||
cmdPath, _ := ffmpegCmd()
|
cmdPath, _ := ffmpegCmd()
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
|
@ -64,23 +62,16 @@ var _ = Describe("ffmpeg", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("createProbeCommand", func() {
|
|
||||||
It("creates a valid command line", func() {
|
|
||||||
args := createProbeCommand(probeCmd, []string{"/music library/one.mp3", "/music library/two.mp3"})
|
|
||||||
Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/one.mp3", "-i", "/music library/two.mp3", "-f", "ffmetadata"}))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
When("ffmpegPath is set", func() {
|
When("ffmpegPath is set", func() {
|
||||||
It("returns the correct ffmpeg path", func() {
|
It("returns the correct ffmpeg path", func() {
|
||||||
ffmpegPath = "/usr/bin/ffmpeg"
|
ffmpegPath = "/usr/bin/ffmpeg"
|
||||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
|
||||||
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "ffmetadata"}))
|
Expect(args).To(Equal([]string{"/usr/bin/ffmpeg", "-i", "one.mp3", "-f", "mp3", "-"}))
|
||||||
})
|
})
|
||||||
It("returns the correct ffmpeg path with spaces", func() {
|
It("returns the correct ffmpeg path with spaces", func() {
|
||||||
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
|
ffmpegPath = "/usr/bin/with spaces/ffmpeg.exe"
|
||||||
args := createProbeCommand(probeCmd, []string{"one.mp3"})
|
args := createFFmpegCommand("ffmpeg -i %s -f mp3 -", "one.mp3", 0, 0)
|
||||||
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
|
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "mp3", "-"}))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -721,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
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ var _ = Describe("patchFLACDuration", func() {
|
||||||
}
|
}
|
||||||
|
|
||||||
readAll := func(in []byte, duration float32) []byte {
|
readAll := func(in []byte, duration float32) []byte {
|
||||||
out, err := io.ReadAll(patchPipedHeader(io.NopCloser(bytes.NewReader(in)), duration))
|
out, err := io.ReadAll(patchFLACDuration(io.NopCloser(bytes.NewReader(in)), duration))
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
@ -118,14 +118,14 @@ var _ = Describe("patchFLACDuration", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
It("propagates a read error from the underlying stream", func() {
|
It("propagates a read error from the underlying stream", func() {
|
||||||
_, err := io.ReadAll(patchPipedHeader(io.NopCloser(io.MultiReader(
|
_, err := io.ReadAll(patchFLACDuration(io.NopCloser(io.MultiReader(
|
||||||
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
|
bytes.NewReader(pipedFLAC()[:10]), &errReader{})), 1.0))
|
||||||
Expect(err).To(MatchError("boom"))
|
Expect(err).To(MatchError("boom"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("closes the underlying stream", func() {
|
It("closes the underlying stream", func() {
|
||||||
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
|
c := &closeSpy{Reader: bytes.NewReader(pipedFLAC())}
|
||||||
Expect(patchPipedHeader(c, 1.0).Close()).To(Succeed())
|
Expect(patchFLACDuration(c, 1.0).Close()).To(Succeed())
|
||||||
Expect(c.closed).To(BeTrue())
|
Expect(c.closed).To(BeTrue())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,143 +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 returns the head of the stream with an Info frame inserted before the first
|
|
||||||
// audio frame, which ffmpeg omits on a pipe since it only knows the frame count once it
|
|
||||||
// can rewind. It returns 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: 576}
|
|
||||||
var sideInfo int
|
|
||||||
mono := (h[3]>>6)&0x03 == 3
|
|
||||||
switch {
|
|
||||||
case mpeg1 && mono:
|
|
||||||
f.samples, sideInfo = 1152, 17
|
|
||||||
case mpeg1:
|
|
||||||
f.samples, sideInfo = 1152, 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,170 +0,0 @@
|
||||||
package ffmpeg
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"testing/iotest"
|
|
||||||
|
|
||||||
. "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("patchMP3Duration", 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())
|
|
||||||
})
|
|
||||||
|
|
||||||
readAll := func(in []byte, duration float32) []byte {
|
|
||||||
out, err := io.ReadAll(patchPipedHeader(io.NopCloser(bytes.NewReader(in)), duration))
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 := readAll(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 := readAll(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 header, not from the source file", func() {
|
|
||||||
in := bytes.Clone(pipedMP3)
|
|
||||||
start := fixtureID3Len
|
|
||||||
in[start+2] = in[start+2]&^0x0C | 0x04 // sample rate index 1: 48000Hz
|
|
||||||
|
|
||||||
out := readAll(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 := readAll(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 := readAll(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 := readAll(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 := readAll(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 := readAll(pipedMP3, 1.0)
|
|
||||||
Expect(readAll(patched, 99.0)).To(Equal(patched))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes through when the duration is zero or negative", func() {
|
|
||||||
Expect(readAll(pipedMP3, 0)).To(Equal(pipedMP3))
|
|
||||||
Expect(readAll(pipedMP3, -5)).To(Equal(pipedMP3))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes through a stream that is not mp3", func() {
|
|
||||||
in := []byte("fLaC\x00\x00\x00\x22 and then some bytes that are not frames")
|
|
||||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
|
||||||
})
|
|
||||||
|
|
||||||
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(readAll(in, 1.0)).To(Equal(in))
|
|
||||||
|
|
||||||
in = monoMP3()
|
|
||||||
in[2] |= 0x0C // sample rate index 3 is reserved
|
|
||||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes through a stream too short to hold a frame header", func() {
|
|
||||||
in := monoMP3()[:3]
|
|
||||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes through a stream whose ID3 tag never ends", func() {
|
|
||||||
in := bytes.Clone(pipedMP3[:200])
|
|
||||||
Expect(readAll(in, 1.0)).To(Equal(in))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("passes through an empty stream", func() {
|
|
||||||
Expect(readAll(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")))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
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