mirror of
https://github.com/navidrome/navidrome.git
synced 2026-11-09 03:38:48 +01:00
Compare commits
34 commits
t3code/fix
...
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 |
144 changed files with 4882 additions and 1335 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.
|
||||||
-->
|
-->
|
||||||
11
.github/workflows/pipeline.yml
vendored
11
.github/workflows/pipeline.yml
vendored
|
|
@ -519,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
|
||||||
|
|
||||||
|
|
@ -541,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 }}
|
||||||
|
|
||||||
|
|
@ -558,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
|
||||||
|
|
|
||||||
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.
|
||||||
|
|
|
||||||
4
Makefile
4
Makefile
|
|
@ -18,7 +18,7 @@ endif
|
||||||
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
SUPPORTED_PLATFORMS ?= linux/amd64,linux/arm64,linux/arm/v5,linux/arm/v6,linux/arm/v7,linux/386,linux/riscv64,darwin/amd64,darwin/arm64,windows/amd64,windows/386
|
||||||
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "linux" | grep -v "arm/v5" | tr '\n' ',' | sed 's/,$$//')
|
||||||
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
PLATFORMS ?= $(SUPPORTED_PLATFORMS)
|
||||||
DOCKER_TAG ?= deluan/navidrome:develop
|
DOCKER_TAG ?= navidrome/navidrome:develop
|
||||||
|
|
||||||
GOLANGCI_LINT_VERSION ?= v2.14.0
|
GOLANGCI_LINT_VERSION ?= v2.14.0
|
||||||
VACUUM_VERSION ?= v0.30.6
|
VACUUM_VERSION ?= v0.30.6
|
||||||
|
|
@ -223,7 +223,7 @@ docker-build: ##@Cross_Compilation Cross-compile for any supported platform (che
|
||||||
--output "./binaries" --target binary .
|
--output "./binaries" --target binary .
|
||||||
.PHONY: docker-build
|
.PHONY: docker-build
|
||||||
|
|
||||||
docker-image: ##@Cross_Compilation Build Docker image, tagged as `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() {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
28
cmd/root.go
28
cmd/root.go
|
|
@ -190,16 +190,20 @@ 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().DefaultGet(context.Background(), 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().DefaultGet(context.Background(), 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.
|
||||||
|
|
@ -214,12 +218,12 @@ func runInitialScan(ctx context.Context) func() error {
|
||||||
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)
|
||||||
|
|
@ -227,9 +231,9 @@ func runInitialScan(ctx context.Context) func() error {
|
||||||
case fullScanRequired == "1":
|
case fullScanRequired == "1":
|
||||||
log.Warn(ctx, "Full scan required after migration")
|
log.Warn(ctx, "Full scan required after migration")
|
||||||
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
_ = ds.Property().Delete(ctx, consts.FullScanAfterMigrationFlagKey)
|
||||||
case 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"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
13
cmd/utils.go
13
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,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, artworkArtwork)
|
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)
|
||||||
|
|
@ -110,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
|
||||||
|
|
@ -159,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, artworkArtwork)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,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 (
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,14 @@ 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, artwork artwork.Artwork) 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, artwork: artwork}
|
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
|
artwork artwork.Artwork
|
||||||
}
|
}
|
||||||
|
|
@ -78,8 +79,9 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr
|
||||||
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
log.Debug(ctx, "Zipping album", "name", album[0].Album, "artist", album[0].AlbumArtist, "folder", folder,
|
||||||
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
"format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album))
|
||||||
for _, mf := range album {
|
for _, mf := range album {
|
||||||
file := a.albumFilename(mf, format, isMultiDisc, folder)
|
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
|
||||||
|
|
@ -204,8 +206,9 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st
|
||||||
|
|
||||||
zippedMfs := make(model.MediaFiles, len(mfs))
|
zippedMfs := make(model.MediaFiles, len(mfs))
|
||||||
for idx, mf := range mfs {
|
for idx, mf := range mfs {
|
||||||
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()
|
||||||
|
|
@ -251,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
|
||||||
|
|
@ -259,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() {
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ var _ = Describe("Archiver", func() {
|
||||||
var (
|
var (
|
||||||
arch core.Archiver
|
arch core.Archiver
|
||||||
ms *mockMediaStreamer
|
ms *mockMediaStreamer
|
||||||
|
dc *fakeDecider
|
||||||
ds *mockDataStore
|
ds *mockDataStore
|
||||||
sh *mockShare
|
sh *mockShare
|
||||||
ca *mockCoverArt
|
ca *mockCoverArt
|
||||||
|
|
@ -33,10 +34,11 @@ var _ = Describe("Archiver", func() {
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ms = &mockMediaStreamer{}
|
ms = &mockMediaStreamer{}
|
||||||
|
dc = &fakeDecider{}
|
||||||
sh = &mockShare{}
|
sh = &mockShare{}
|
||||||
ds = &mockDataStore{}
|
ds = &mockDataStore{}
|
||||||
ca = &mockCoverArt{images: map[string][]byte{}}
|
ca = &mockCoverArt{images: map[string][]byte{}}
|
||||||
arch = core.NewArchiver(ms, ds, sh, ca)
|
arch = core.NewArchiver(ms, dc, ds, sh, ca)
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("ZipAlbum", func() {
|
Context("ZipAlbum", func() {
|
||||||
|
|
@ -66,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() {
|
||||||
|
|
@ -296,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"}},
|
||||||
|
|
@ -571,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
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, p
|
||||||
if libFS == nil {
|
if libFS == nil {
|
||||||
return nil, "", fmt.Errorf("artist folder lookup unavailable")
|
return nil, "", fmt.Errorf("artist folder lookup unavailable")
|
||||||
}
|
}
|
||||||
rel, err := utils.RelPath(libPath, artistFolder)
|
rel, err := filepath.Rel(libPath, artistFolder)
|
||||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||||
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
|
return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -374,8 +374,11 @@ func (r *resolver) resolvePlaylist(ctx context.Context, playlistID string) (reso
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
albumIDs, err := r.ds.Playlist().Tracks(ctx, pl.ID, false).
|
tracks := r.ds.Playlist().Tracks(ctx, pl.ID, false)
|
||||||
GetAlbumIDs(ctx, 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{}))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"runtime/debug"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -28,10 +28,6 @@ const (
|
||||||
// giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an
|
// giveUpAfter bounds the retry budget from enqueue; past it the item settles and only an
|
||||||
// explicit reprocess retries it.
|
// explicit reprocess retries it.
|
||||||
giveUpAfter = 12 * time.Hour
|
giveUpAfter = 12 * time.Hour
|
||||||
// itemTimeout must outlast a slow but healthy item: each image agent's call plus its download, in turn.
|
|
||||||
itemTimeout = 3 * time.Minute
|
|
||||||
// shutdownGrace lets cancelled work unwind before Run returns and the DB closes, without waiting on a stuck item.
|
|
||||||
shutdownGrace = 5 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// drainPool drains one class of work with its own slot budget, so a blocking kind cannot
|
// drainPool drains one class of work with its own slot budget, so a blocking kind cannot
|
||||||
|
|
@ -56,13 +52,8 @@ type Worker struct {
|
||||||
|
|
||||||
gatesMu sync.Mutex
|
gatesMu sync.Mutex
|
||||||
gates map[string]*extGate
|
gates map[string]*extGate
|
||||||
|
|
||||||
// busy holds the itemKeys still being acquired, including ones a drain stopped waiting for.
|
|
||||||
busy sync.Map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type itemKey struct{ kind, id, imageType string }
|
|
||||||
|
|
||||||
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
|
func NewWorker(ds model.DataStore, store *ImageStore, ag *agents.Agents, ffmpeg ffmpeg.FFmpeg, broker events.Broker, imgCache cache.FileCache) *Worker {
|
||||||
w := &Worker{
|
w := &Worker{
|
||||||
proc: &processor{ds: ds, store: store},
|
proc: &processor{ds: ds, store: store},
|
||||||
|
|
@ -253,56 +244,10 @@ func (w *Worker) broadcastRefresh(ctx context.Context, found []model.ArtworkQueu
|
||||||
|
|
||||||
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
|
func (w *Worker) process(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired) {
|
||||||
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
item.ImageType = cmp.Or(item.ImageType, model.ImageTypePrimary)
|
||||||
ctx = withTrace(ctx, &ChainTrace{})
|
trace := &ChainTrace{}
|
||||||
out, got, retryIn := w.acquireWithTimeout(ctx, item)
|
ctx = withTrace(ctx, trace)
|
||||||
if ctx.Err() != nil {
|
out, got, retryIn := w.safeAcquire(ctx, item)
|
||||||
return outcomeFailed, nil // shutting down: leave the row for the next run
|
|
||||||
}
|
|
||||||
w.settle(ctx, item, out, retryIn)
|
|
||||||
return out, got
|
|
||||||
}
|
|
||||||
|
|
||||||
// acquireWithTimeout stops waiting on a stuck item, which Go can't stop, and never runs one item twice at once.
|
|
||||||
func (w *Worker) acquireWithTimeout(ctx context.Context, item model.ArtworkQueueItem) (outcome, *acquired, time.Duration) {
|
|
||||||
key := itemKey{item.ItemKind, item.ItemID, item.ImageType}
|
|
||||||
if _, running := w.busy.LoadOrStore(key, struct{}{}); running {
|
|
||||||
log.Debug(ctx, "Artwork: Item still running from an earlier drain", "kind", item.ItemKind, "id", item.ItemID)
|
|
||||||
traceStage(ctx, "worker", errors.New("previous attempt still running"))
|
|
||||||
return outcomeFailed, nil, 0
|
|
||||||
}
|
|
||||||
type result struct {
|
|
||||||
out outcome
|
|
||||||
got *acquired
|
|
||||||
retryIn time.Duration
|
|
||||||
}
|
|
||||||
done := make(chan result, 1)
|
|
||||||
go func() {
|
|
||||||
// The deadline stops work that honors ctx; the select below covers work that doesn't.
|
|
||||||
actx, cancel := context.WithTimeout(ctx, itemTimeout)
|
|
||||||
defer cancel()
|
|
||||||
out, got, retryIn := w.proc.acquire(actx, item)
|
|
||||||
// Cleared before the send, so the next drain never sees a finished item as busy.
|
|
||||||
w.busy.Delete(key)
|
|
||||||
done <- result{out, got, retryIn}
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case r := <-done:
|
|
||||||
return r.out, r.got, r.retryIn
|
|
||||||
case <-ctx.Done():
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(shutdownGrace):
|
|
||||||
}
|
|
||||||
return outcomeFailed, nil, 0
|
|
||||||
case <-time.After(itemTimeout):
|
|
||||||
log.Error(ctx, "Artwork: Item timed out, moving on", "kind", item.ItemKind, "id", item.ItemID, "timeout", itemTimeout)
|
|
||||||
traceStage(ctx, "worker", fmt.Errorf("timed out after %s", itemTimeout))
|
|
||||||
return outcomeFailed, nil, 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// settle deletes or reschedules the queue row for an acquire outcome.
|
|
||||||
func (w *Worker) settle(ctx context.Context, item model.ArtworkQueueItem, out outcome, retryIn time.Duration) {
|
|
||||||
queue := w.proc.ds.ArtworkQueue()
|
queue := w.proc.ds.ArtworkQueue()
|
||||||
switch out {
|
switch out {
|
||||||
case outcomeFound, outcomeAbsent:
|
case outcomeFound, outcomeAbsent:
|
||||||
|
|
@ -313,7 +258,7 @@ func (w *Worker) settle(ctx context.Context, item model.ArtworkQueueItem, out ou
|
||||||
}
|
}
|
||||||
case outcomeFoundStale, outcomeFailed:
|
case outcomeFoundStale, outcomeFailed:
|
||||||
retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn))
|
retryAt := time.Now().Add(retryDelay(item.Attempts, retryIn))
|
||||||
encoded := traceFrom(ctx).encode("")
|
encoded := trace.encode("")
|
||||||
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.
|
||||||
|
|
@ -340,6 +285,21 @@ func (w *Worker) settle(ctx context.Context, item model.ArtworkQueueItem, out ou
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,18 @@ func (v *visibilityPlaylistRepo) Get(ctx context.Context, id string) (*model.Pla
|
||||||
return v.MockPlaylistRepo.Get(ctx, id)
|
return v.MockPlaylistRepo.Get(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type panickingAlbumRepo struct {
|
||||||
|
*tests.MockAlbumRepo
|
||||||
|
panicID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *panickingAlbumRepo) Get(ctx context.Context, id string) (*model.Album, error) {
|
||||||
|
if id == r.panicID {
|
||||||
|
panic("boom")
|
||||||
|
}
|
||||||
|
return r.MockAlbumRepo.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
func adminUserRepo() *tests.MockedUserRepo {
|
func adminUserRepo() *tests.MockedUserRepo {
|
||||||
repo := tests.CreateMockUserRepo()
|
repo := tests.CreateMockUserRepo()
|
||||||
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
Expect(repo.Put(GinkgoT().Context(), &model.User{ID: "admin", UserName: "admin", IsAdmin: true})).To(Succeed())
|
||||||
|
|
@ -278,6 +290,36 @@ var _ = Describe("Worker", func() {
|
||||||
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
Expect(err).To(MatchError(model.ErrNotFound), "a timeout must never settle on absent")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("fails an item that panics, without stopping the rest of the batch", func() {
|
||||||
|
folderRepo.result = []model.Folder{{
|
||||||
|
Path: "tests/fixtures/artist/an-album",
|
||||||
|
ImageFiles: []string{"cover.jpg"},
|
||||||
|
}}
|
||||||
|
albums := tests.CreateMockAlbumRepo()
|
||||||
|
albums.SetData(model.Albums{
|
||||||
|
{ID: "alboom", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
|
{ID: "alok", Name: "Album", FolderIDs: []string{"f1"}},
|
||||||
|
})
|
||||||
|
ds.MockedAlbum = &panickingAlbumRepo{MockAlbumRepo: albums, panicID: "alboom"}
|
||||||
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alboom"})).To(Succeed())
|
||||||
|
Expect(queueRepo.Enqueue(ctx, model.ArtworkQueueItem{ItemKind: "al", ItemID: "alok"})).To(Succeed())
|
||||||
|
|
||||||
|
n, err := w.drain(ctx, 1)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(n).To(Equal(2))
|
||||||
|
|
||||||
|
it := findQueued(queueRepo, "al", "alboom")
|
||||||
|
Expect(it).ToNot(BeNil(), "a panicking item must be rescheduled, not dropped")
|
||||||
|
Expect(it.Attempts).To(Equal(1))
|
||||||
|
Expect(it.RetryAt).To(BeTemporally(">", time.Now()))
|
||||||
|
Expect(it.Trace).To(ContainSubstring("boom"))
|
||||||
|
|
||||||
|
Expect(findQueued(queueRepo, "al", "alok")).To(BeNil())
|
||||||
|
ia, err := artRepo.GetItemArtwork(ctx, model.KindAlbumArtwork, "alok", model.ImageTypePrimary)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(ia.Source).To(Equal("folder"))
|
||||||
|
})
|
||||||
|
|
||||||
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
|
It("reschedules past the provider's requested delay when it exceeds the backoff", func() {
|
||||||
conf.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"}})
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
package artwork
|
package artwork
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"github.com/navidrome/navidrome/core/agents"
|
"github.com/navidrome/navidrome/core/agents"
|
||||||
"github.com/navidrome/navidrome/model"
|
|
||||||
"github.com/navidrome/navidrome/tests"
|
"github.com/navidrome/navidrome/tests"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -117,109 +113,3 @@ func TestArtworkGatePerAgentBreakerIsolation(t *testing.T) {
|
||||||
g.Expect(aCalls).To(Equal(1))
|
g.Expect(aCalls).To(Equal(1))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// newStuckItemWorker queues one artist whose agent blocks until the returned channel is closed.
|
|
||||||
func newStuckItemWorker(t *testing.T) (*Worker, *tests.MockArtworkQueueRepo, *fakeImageAgent, chan struct{}) {
|
|
||||||
t.Cleanup(configtest.SetupConfig())
|
|
||||||
conf.Server.ArtistArtPriority = "external"
|
|
||||||
conf.Server.DevArtworkExternalMaxRPS = 1000
|
|
||||||
|
|
||||||
block := make(chan struct{})
|
|
||||||
agent := &fakeImageAgent{name: "stuckAgent", block: block}
|
|
||||||
ag := imageAgents(agent)
|
|
||||||
artists := tests.CreateMockArtistRepo()
|
|
||||||
artists.SetData(model.Artists{{ID: "ar1", Name: "Artist"}})
|
|
||||||
queue := tests.CreateMockArtworkQueueRepo()
|
|
||||||
ds := &tests.MockDataStore{MockedArtist: artists, MockedFolder: &fakeFolderRepo{}, MockedArtwork: tests.CreateMockArtworkRepo(), MockedArtworkQueue: queue}
|
|
||||||
w := NewWorker(ds, NewImageStore(t.TempDir()), ag, tests.NewMockFFmpeg(""), &fakeEventBroker{}, nil)
|
|
||||||
if err := queue.Enqueue(t.Context(), model.ArtworkQueueItem{ItemKind: "ar", ItemID: "ar1"}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return w, queue, agent, block
|
|
||||||
}
|
|
||||||
|
|
||||||
// Go can't stop a stuck item, so the drain must stop waiting for it, or the whole pool stalls.
|
|
||||||
func TestArtworkDrainMovesOnFromAStuckItem(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
g := NewWithT(t)
|
|
||||||
w, queue, agent, block := newStuckItemWorker(t)
|
|
||||||
ctx := t.Context()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
n, err := w.drain(ctx, 1)
|
|
||||||
g.Expect(err).ToNot(HaveOccurred())
|
|
||||||
g.Expect(n).To(Equal(1))
|
|
||||||
g.Expect(time.Since(start)).To(Equal(itemTimeout), "the drain stops waiting once the item times out")
|
|
||||||
row := findQueued(queue, "ar", "ar1")
|
|
||||||
g.Expect(row).ToNot(BeNil(), "a timed-out item is rescheduled, not dropped")
|
|
||||||
g.Expect(row.Attempts).To(Equal(1))
|
|
||||||
g.Expect(row.RetryAt).To(BeTemporally(">", time.Now()))
|
|
||||||
g.Expect(row.Trace).To(ContainSubstring("timed out"))
|
|
||||||
|
|
||||||
// Due again while the first run is still stuck: it must not start a second one.
|
|
||||||
time.Sleep(time.Until(row.RetryAt))
|
|
||||||
start = time.Now()
|
|
||||||
_, err = w.drain(ctx, 1)
|
|
||||||
g.Expect(err).ToNot(HaveOccurred())
|
|
||||||
g.Expect(time.Since(start)).To(BeZero(), "a still-running item is rescheduled without waiting")
|
|
||||||
row = findQueued(queue, "ar", "ar1")
|
|
||||||
g.Expect(row.Attempts).To(Equal(2))
|
|
||||||
g.Expect(row.Trace).To(ContainSubstring("still running"))
|
|
||||||
|
|
||||||
// Once the stuck run returns, the next attempt runs normally and settles the row.
|
|
||||||
close(block)
|
|
||||||
synctest.Wait()
|
|
||||||
time.Sleep(time.Until(row.RetryAt))
|
|
||||||
_, err = w.drain(ctx, 1)
|
|
||||||
g.Expect(err).ToNot(HaveOccurred())
|
|
||||||
g.Expect(findQueued(queue, "ar", "ar1")).To(BeNil())
|
|
||||||
g.Expect(agent.artistCalls).To(Equal(2), "one stuck call and one retry, never two at once")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown joins cancelled work that unwinds, so nothing touches the DB after Run returns.
|
|
||||||
func TestArtworkDrainShutdownWaitsForWorkToUnwind(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
g := NewWithT(t)
|
|
||||||
w, queue, _, block := newStuckItemWorker(t)
|
|
||||||
before := *findQueued(queue, "ar", "ar1")
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
go func() {
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
cancel()
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
close(block)
|
|
||||||
}()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
_, err := w.drain(ctx, 1)
|
|
||||||
g.Expect(err).ToNot(HaveOccurred())
|
|
||||||
g.Expect(time.Since(start)).To(Equal(time.Second+100*time.Millisecond), "the drain waits for the cancelled item to return")
|
|
||||||
_, running := w.busy.Load(itemKey{"ar", "ar1", model.ImageTypePrimary})
|
|
||||||
g.Expect(running).To(BeFalse())
|
|
||||||
g.Expect(*findQueued(queue, "ar", "ar1")).To(Equal(before), "the row is left for the next run")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown must not wait on a stuck item for longer than the grace period.
|
|
||||||
func TestArtworkDrainShutdownDoesNotWaitOnAStuckItem(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
g := NewWithT(t)
|
|
||||||
w, queue, _, block := newStuckItemWorker(t)
|
|
||||||
before := *findQueued(queue, "ar", "ar1")
|
|
||||||
ctx, cancel := context.WithCancel(t.Context())
|
|
||||||
go func() {
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
cancel()
|
|
||||||
}()
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
_, err := w.drain(ctx, 1)
|
|
||||||
g.Expect(err).ToNot(HaveOccurred())
|
|
||||||
g.Expect(time.Since(start)).To(Equal(time.Second+shutdownGrace), "the drain gives up on the stuck item after the grace period")
|
|
||||||
g.Expect(*findQueued(queue, "ar", "ar1")).To(Equal(before), "the row is left for the next run")
|
|
||||||
|
|
||||||
close(block)
|
|
||||||
synctest.Wait()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ type InspectOutput struct {
|
||||||
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
|
MappedTags *model.MediaFile `json:"mappedTags,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, error) {
|
func Inspect(filePath string, lib model.Library, folderId string) (*InspectOutput, error) {
|
||||||
path, file := filepath.Split(filePath)
|
path, file := filepath.Split(filePath)
|
||||||
|
|
||||||
s, err := storage.For(path)
|
s, err := storage.For(path)
|
||||||
|
|
@ -39,12 +39,22 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
md := metadata.New(path, tag)
|
md := metadata.New(scannerPath(lib, filePath), tag)
|
||||||
result := &InspectOutput{
|
result := &InspectOutput{
|
||||||
File: filePath,
|
File: filePath,
|
||||||
RawTags: tags[file].Tags,
|
RawTags: tags[file].Tags,
|
||||||
MappedTags: new(md.ToMediaFile(libraryId, folderId)),
|
MappedTags: new(md.ToMediaFile(lib, folderId)),
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scannerPath returns the path the scanner uses for the file (relative to its library), so
|
||||||
|
// folder-based PIDs match the DB. Files outside the library keep their absolute path.
|
||||||
|
func scannerPath(lib model.Library, filePath string) string {
|
||||||
|
absPath, err := filepath.Abs(filePath)
|
||||||
|
if err != nil || lib.Path == "" {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
return model.LibraryRelativePath(lib.Path, absPath)
|
||||||
|
}
|
||||||
|
|
|
||||||
45
core/inspect_test.go
Normal file
45
core/inspect_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package core_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/core"
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("Inspect", func() {
|
||||||
|
var fixtures string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
var err error
|
||||||
|
fixtures, err = filepath.Abs(filepath.Join("tests", "fixtures"))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("maps the file with the library-relative path the scanner uses", func() {
|
||||||
|
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
|
||||||
|
out, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(out.MappedTags.Path).To(Equal("fixtures/test.mp3"))
|
||||||
|
Expect(out.MappedTags.LibraryID).To(Equal(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("gives the same IDs for relative and absolute paths", func() {
|
||||||
|
lib := model.Library{ID: 2, Path: filepath.Dir(fixtures), PIDAlbum: "folder"}
|
||||||
|
abs, err := core.Inspect(filepath.Join(fixtures, "test.mp3"), lib, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
rel, err := core.Inspect(filepath.Join("tests", "fixtures", "test.mp3"), lib, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rel.MappedTags.AlbumID).To(Equal(abs.MappedTags.AlbumID))
|
||||||
|
Expect(rel.MappedTags.PID).To(Equal(abs.MappedTags.PID))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps the given path for a file outside the library", func() {
|
||||||
|
filePath := filepath.Join(fixtures, "test.mp3")
|
||||||
|
out, err := core.Inspect(filePath, model.Library{ID: model.DefaultLibraryID}, "")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(out.MappedTags.Path).To(Equal(filePath))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -2,10 +2,12 @@ package core
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -15,6 +17,7 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/model/metadata"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
|
|
@ -200,24 +203,23 @@ func (r *libraryRepositoryWrapper) Update(ctx context.Context, id string, entity
|
||||||
}
|
}
|
||||||
|
|
||||||
pathChanged := originalLib.Path != lib.Path
|
pathChanged := originalLib.Path != lib.Path
|
||||||
|
pidChanged := (updatesColumn(cols, "pidAlbum") && originalLib.PIDAlbum != lib.PIDAlbum) ||
|
||||||
|
(updatesColumn(cols, "pidTrack") && originalLib.PIDTrack != lib.PIDTrack)
|
||||||
|
|
||||||
err = r.LibraryRepository.Put(ctx, lib, cols...)
|
err = r.LibraryRepository.Put(ctx, lib, cols...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.mapError(err)
|
return r.mapError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restart watcher and trigger scan if path was updated
|
if pathChanged && r.watcher != nil {
|
||||||
if pathChanged {
|
|
||||||
if r.watcher != nil {
|
|
||||||
if err := r.watcher.Watch(ctx, lib); err != nil {
|
if err := r.watcher.Watch(ctx, lib); err != nil {
|
||||||
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
log.Warn(ctx, "Failed to restart watcher for updated library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if r.scanner != nil {
|
if (pathChanged || pidChanged) && r.scanner != nil {
|
||||||
go r.triggerScan(ctx, lib, "updated")
|
go r.triggerScan(ctx, lib, "updated")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Send library refresh event to all clients
|
// Send library refresh event to all clients
|
||||||
if r.broker != nil {
|
if r.broker != nil {
|
||||||
|
|
@ -325,6 +327,15 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
library.PIDAlbum = strings.TrimSpace(library.PIDAlbum)
|
||||||
|
library.PIDTrack = strings.TrimSpace(library.PIDTrack)
|
||||||
|
if err := metadata.ValidatePIDSpec(library.PIDAlbum, true); err != nil {
|
||||||
|
validationErrors["pidAlbum"] = err.Error()
|
||||||
|
}
|
||||||
|
if err := metadata.ValidatePIDSpec(library.PIDTrack, false); err != nil {
|
||||||
|
validationErrors["pidTrack"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
if len(validationErrors) > 0 {
|
if len(validationErrors) > 0 {
|
||||||
return &rest.ValidationError{Errors: validationErrors}
|
return &rest.ValidationError{Errors: validationErrors}
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +343,11 @@ func (r *libraryRepositoryWrapper) validateLibrary(ctx context.Context, library
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updatesColumn reports whether an update with these columns writes col. No columns means all of them.
|
||||||
|
func updatesColumn(cols []string, col string) bool {
|
||||||
|
return len(cols) == 0 || slices.Contains(cols, col)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
|
func (r *libraryRepositoryWrapper) validateLibraryPath(ctx context.Context, library *model.Library) error {
|
||||||
// Validate path format
|
// Validate path format
|
||||||
if !filepath.IsAbs(library.Path) {
|
if !filepath.IsAbs(library.Path) {
|
||||||
|
|
@ -399,11 +415,27 @@ func (s *libraryService) validateLibraryIDs(ctx context.Context, libraryIDs []in
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var scanWaitInterval = time.Second
|
||||||
|
|
||||||
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
|
func (r *libraryRepositoryWrapper) triggerScan(ctx context.Context, lib *model.Library, action string) {
|
||||||
|
// Runs in its own goroutine and outlives the HTTP request
|
||||||
|
ctx = context.WithoutCancel(ctx)
|
||||||
|
|
||||||
|
// A running scan loaded the libraries before this change, and would reject a new request
|
||||||
|
for {
|
||||||
|
status, err := r.scanner.Status(ctx)
|
||||||
|
if err != nil || !status.Scanning {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(scanWaitInterval)
|
||||||
|
}
|
||||||
|
|
||||||
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
log.Info(ctx, fmt.Sprintf("Triggering scan for %s library", action), "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan for new library
|
warnings, err := r.scanner.ScanAll(ctx, false) // Quick scan: the scanner rescans libraries with a changed PID config in full
|
||||||
if err != nil {
|
if errors.Is(err, model.ErrAlreadyScanning) {
|
||||||
|
log.Debug(ctx, "Scan already running, it covers this change", "libraryID", lib.ID, "name", lib.Name)
|
||||||
|
} else if err != nil {
|
||||||
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
log.Error(ctx, fmt.Sprintf("Error scanning %s library", action), "libraryID", lib.ID, "name", lib.Name, err)
|
||||||
} else {
|
} else {
|
||||||
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
log.Info(ctx, fmt.Sprintf("Scan completed for %s library", action), "libraryID", lib.ID, "name", lib.Name, "warnings", len(warnings), "elapsed", time.Since(start))
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,37 @@ var _ = Describe("Library Service", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("PID validation", func() {
|
||||||
|
pidError := func(err error, field string) string {
|
||||||
|
var validationErr *rest.ValidationError
|
||||||
|
Expect(errors.As(err, &validationErr)).To(BeTrue())
|
||||||
|
return validationErr.Errors[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
It("rejects an unknown attribute in the album PID", func() {
|
||||||
|
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albmversion"})
|
||||||
|
Expect(pidError(err, "pidAlbum")).To(ContainSubstring(`unknown attribute "albmversion"`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects albumid in the album PID", func() {
|
||||||
|
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: "albumid"})
|
||||||
|
Expect(pidError(err, "pidAlbum")).To(ContainSubstring("albumid"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects an unknown attribute in the track PID", func() {
|
||||||
|
_, err := repo.Save(ctx, &model.Library{Name: "Lib", Path: tempDir, PIDTrack: "nosuchtag"})
|
||||||
|
Expect(pidError(err, "pidTrack")).To(ContainSubstring(`unknown attribute "nosuchtag"`))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("trims spaces", func() {
|
||||||
|
library := &model.Library{Name: "Lib", Path: tempDir, PIDAlbum: " folder ", PIDTrack: " "}
|
||||||
|
_, err := repo.Save(ctx, library)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(library.PIDAlbum).To(Equal("folder"))
|
||||||
|
Expect(library.PIDTrack).To(BeEmpty())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("Path Validation", func() {
|
Describe("Path Validation", func() {
|
||||||
Context("Create operation", func() {
|
Context("Create operation", func() {
|
||||||
It("fails when path is not absolute", func() {
|
It("fails when path is not absolute", func() {
|
||||||
|
|
@ -679,6 +710,48 @@ var _ = Describe("Library Service", func() {
|
||||||
}, "100ms", "10ms").Should(Equal(0))
|
}, "100ms", "10ms").Should(Equal(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("triggers scan when updating the library PID config", func() {
|
||||||
|
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
|
||||||
|
|
||||||
|
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
|
||||||
|
Expect(repo.Update(ctx, "1", library)).To(Succeed())
|
||||||
|
|
||||||
|
Eventually(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "1s", "10ms").Should(Equal(1))
|
||||||
|
// A quick scan: the scanner itself rescans this library in full
|
||||||
|
Expect(scanner.GetScanAllCalls()[0].FullScan).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not trigger scan when the PID fields were not sent", func() {
|
||||||
|
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}})
|
||||||
|
|
||||||
|
// The REST layer decodes a missing pidAlbum as "". Only the sent fields count.
|
||||||
|
library := model.Library{ID: 1, Name: "Renamed", Path: tempDir}
|
||||||
|
Expect(repo.Update(ctx, "1", library, "name", "path")).To(Succeed())
|
||||||
|
|
||||||
|
Consistently(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "100ms", "10ms").Should(Equal(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("waits for a running scan before triggering a new one", func() {
|
||||||
|
libraryRepo.SetData(model.Libraries{{ID: 1, Name: "Library", Path: tempDir}})
|
||||||
|
scanner.SetScanning(true)
|
||||||
|
|
||||||
|
library := model.Library{ID: 1, Name: "Library", Path: tempDir, PIDAlbum: "folder"}
|
||||||
|
Expect(repo.Update(ctx, "1", library)).To(Succeed())
|
||||||
|
|
||||||
|
Consistently(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "200ms", "20ms").Should(Equal(0))
|
||||||
|
|
||||||
|
scanner.SetScanning(false)
|
||||||
|
Eventually(func() int {
|
||||||
|
return scanner.GetScanAllCallCount()
|
||||||
|
}, "3s", "20ms").Should(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
It("does not trigger scan when library creation fails", func() {
|
It("does not trigger scan when library creation fails", func() {
|
||||||
// Try to create library with invalid data (empty name)
|
// Try to create library with invalid data (empty name)
|
||||||
library := &model.Library{Path: tempDir}
|
library := &model.Library{Path: tempDir}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
@ -269,9 +270,13 @@ func (c *insightsCollector) collect(ctx context.Context) []byte {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading radios count", err)
|
log.Trace(ctx, "Error reading radios count", err)
|
||||||
}
|
}
|
||||||
data.Library.Libraries, err = c.ds.Library().CountAll(ctx)
|
libs, err := c.ds.Library().GetAll(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Trace(ctx, "Error reading libraries count", err)
|
log.Trace(ctx, "Error reading libraries", err)
|
||||||
|
}
|
||||||
|
data.Library.Libraries = int64(len(libs))
|
||||||
|
if slices.ContainsFunc(libs, func(lib model.Library) bool { return lib.PIDAlbum != "" || lib.PIDTrack != "" }) {
|
||||||
|
data.Config.HasCustomPID = true
|
||||||
}
|
}
|
||||||
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
|
data.Library.ActiveUsers, err = c.ds.User().CountAll(ctx, model.QueryOptions{
|
||||||
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
Filters: squirrel.Gt{"last_access_at": time.Now().Add(-7 * 24 * time.Hour)},
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,8 @@ func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folde
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
matcher := newLibraryMatcher(libs)
|
matcher := model.NewLibraryMatcher(libs)
|
||||||
lib, ok := matcher.findLibrary(dir)
|
lib, ok := matcher.FindLibrary(dir)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
|
return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
package playlists
|
package playlists
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -15,7 +13,6 @@ import (
|
||||||
"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/model/request"
|
||||||
"github.com/navidrome/navidrome/utils"
|
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
"golang.org/x/text/unicode/norm"
|
"golang.org/x/text/unicode/norm"
|
||||||
)
|
)
|
||||||
|
|
@ -149,7 +146,7 @@ func (r pathResolution) ToQualifiedString() (string, error) {
|
||||||
if !r.valid {
|
if !r.valid {
|
||||||
return "", fmt.Errorf("invalid path resolution")
|
return "", fmt.Errorf("invalid path resolution")
|
||||||
}
|
}
|
||||||
relativePath, err := utils.RelPath(r.libraryPath, r.absolutePath)
|
relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
@ -157,61 +154,9 @@ func (r pathResolution) ToQualifiedString() (string, error) {
|
||||||
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
|
return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
|
|
||||||
type libraryMatcher struct {
|
|
||||||
libraries model.Libraries
|
|
||||||
cleanedPaths []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLibraryForPath finds which library contains the given absolute path.
|
|
||||||
// Returns library ID and path, or 0 and empty string if not found.
|
|
||||||
func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) {
|
|
||||||
lib, ok := lm.findLibrary(absolutePath)
|
|
||||||
if !ok {
|
|
||||||
return 0, ""
|
|
||||||
}
|
|
||||||
return lib.ID, filepath.Clean(lib.Path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// findLibrary checks if the absolute path is under any of the library paths.
|
|
||||||
func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) {
|
|
||||||
// Check sorted libraries (longest path first) to find the best match
|
|
||||||
for i, cleanLibPath := range lm.cleanedPaths {
|
|
||||||
// Check if absolutePath is under this library path
|
|
||||||
if strings.HasPrefix(absolutePath, cleanLibPath) {
|
|
||||||
// Ensure it's a proper path boundary (not just a prefix)
|
|
||||||
if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator {
|
|
||||||
return lm.libraries[i], true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return model.Library{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
|
|
||||||
// This ensures correct matching when library paths are prefixes of each other.
|
|
||||||
// Example: /music-classical must be checked before /music
|
|
||||||
// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
|
|
||||||
func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
|
|
||||||
// Sort libraries by path length (descending) to ensure longest paths match first.
|
|
||||||
slices.SortFunc(libs, func(i, j model.Library) int {
|
|
||||||
return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
|
|
||||||
})
|
|
||||||
|
|
||||||
// Pre-clean all library paths once for efficient matching
|
|
||||||
cleanedPaths := make([]string, len(libs))
|
|
||||||
for i, lib := range libs {
|
|
||||||
cleanedPaths[i] = filepath.Clean(lib.Path)
|
|
||||||
}
|
|
||||||
return &libraryMatcher{
|
|
||||||
libraries: libs,
|
|
||||||
cleanedPaths: cleanedPaths,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pathResolver handles path resolution logic for playlist imports.
|
// pathResolver handles path resolution logic for playlist imports.
|
||||||
type pathResolver struct {
|
type pathResolver struct {
|
||||||
matcher *libraryMatcher
|
matcher *model.LibraryMatcher
|
||||||
}
|
}
|
||||||
|
|
||||||
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
|
// newPathResolver creates a pathResolver with libraries loaded from the datastore.
|
||||||
|
|
@ -220,7 +165,7 @@ func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, er
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
matcher := newLibraryMatcher(libs)
|
matcher := model.NewLibraryMatcher(libs)
|
||||||
return &pathResolver{matcher: matcher}, nil
|
return &pathResolver{matcher: matcher}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,14 +192,14 @@ func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolu
|
||||||
// a pathResolution with the library information. Returns an invalid resolution if
|
// a pathResolution with the library information. Returns an invalid resolution if
|
||||||
// the path is not found in any library.
|
// the path is not found in any library.
|
||||||
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
|
||||||
libID, libPath := r.matcher.findLibraryForPath(absolutePath)
|
lib, ok := r.matcher.FindLibrary(absolutePath)
|
||||||
if libID == 0 {
|
if !ok {
|
||||||
return pathResolution{valid: false}
|
return pathResolution{valid: false}
|
||||||
}
|
}
|
||||||
return pathResolution{
|
return pathResolution{
|
||||||
absolutePath: absolutePath,
|
absolutePath: absolutePath,
|
||||||
libraryPath: libPath,
|
libraryPath: filepath.Clean(lib.Path),
|
||||||
libraryID: libID,
|
libraryID: lib.ID,
|
||||||
valid: true,
|
valid: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -289,7 +234,7 @@ func (r *pathResolver) resolvePaths(ctx context.Context, folder *model.Folder, l
|
||||||
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
|
// HTTP(S) URLs are stored as-is (gated by EnableM3UExternalAlbumArt).
|
||||||
// Local paths (file://, absolute, or relative) are resolved to an absolute path
|
// Local paths (file://, absolute, or relative) are resolved to an absolute path
|
||||||
// and validated against known library boundaries via matcher.
|
// and validated against known library boundaries via matcher.
|
||||||
func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher, owner model.User) string {
|
func resolveImageURL(value string, folder *model.Folder, matcher *model.LibraryMatcher, owner model.User) string {
|
||||||
value = strings.TrimSpace(value)
|
value = strings.TrimSpace(value)
|
||||||
if value == "" {
|
if value == "" {
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -309,7 +254,7 @@ func resolveImageURL(value string, folder *model.Folder, matcher *libraryMatcher
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
lib, ok := matcher.findLibrary(localPath)
|
lib, ok := matcher.FindLibrary(localPath)
|
||||||
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
|
// A playlist without a folder (API upload, or CLI import from outside all libraries) may only use the owner's libraries.
|
||||||
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
|
if !ok || (folder == nil && !owner.HasLibraryAccess(lib.ID)) {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
||||||
|
|
@ -9,187 +9,6 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("libraryMatcher", func() {
|
|
||||||
var ds *tests.MockDataStore
|
|
||||||
var mockLibRepo *tests.MockLibraryRepo
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
BeforeEach(func() {
|
|
||||||
tests.SkipOnWindows("path separator bug (#TBD-path-sep-playlists)")
|
|
||||||
mockLibRepo = &tests.MockLibraryRepo{}
|
|
||||||
ds = &tests.MockDataStore{
|
|
||||||
MockedLibrary: mockLibRepo,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Helper function to create a libraryMatcher from the mock datastore
|
|
||||||
createMatcher := func(ds model.DataStore) *libraryMatcher {
|
|
||||||
libs, err := ds.Library().GetAll(ctx)
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
return newLibraryMatcher(libs)
|
|
||||||
}
|
|
||||||
|
|
||||||
Describe("Longest library path matching", func() {
|
|
||||||
It("matches the longest library path when multiple libraries share a prefix", func() {
|
|
||||||
// Setup libraries with prefix conflicts
|
|
||||||
mockLibRepo.SetData([]model.Library{
|
|
||||||
{ID: 1, Path: "/music"},
|
|
||||||
{ID: 2, Path: "/music-classical"},
|
|
||||||
{ID: 3, Path: "/music-classical/opera"},
|
|
||||||
})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
|
|
||||||
// Test that longest path matches first and returns correct library ID
|
|
||||||
testCases := []struct {
|
|
||||||
path string
|
|
||||||
expectedLibID int
|
|
||||||
expectedLibPath string
|
|
||||||
}{
|
|
||||||
{"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
|
|
||||||
{"/music-classical/track.mp3", 2, "/music-classical"},
|
|
||||||
{"/music/track.mp3", 1, "/music"},
|
|
||||||
{"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
libID, libPath := matcher.findLibraryForPath(tc.path)
|
|
||||||
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
|
|
||||||
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles libraries with similar prefixes but different structures", func() {
|
|
||||||
mockLibRepo.SetData([]model.Library{
|
|
||||||
{ID: 1, Path: "/home/user/music"},
|
|
||||||
{ID: 2, Path: "/home/user/music-backup"},
|
|
||||||
})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
|
|
||||||
// Test that music-backup library is matched correctly
|
|
||||||
libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
|
|
||||||
Expect(libID).To(Equal(2))
|
|
||||||
Expect(libPath).To(Equal("/home/user/music-backup"))
|
|
||||||
|
|
||||||
// Test that music library is still matched correctly
|
|
||||||
libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
|
|
||||||
Expect(libID).To(Equal(1))
|
|
||||||
Expect(libPath).To(Equal("/home/user/music"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("matches path that is exactly the library root", func() {
|
|
||||||
mockLibRepo.SetData([]model.Library{
|
|
||||||
{ID: 1, Path: "/music"},
|
|
||||||
{ID: 2, Path: "/music-classical"},
|
|
||||||
})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
|
|
||||||
// Exact library path should match
|
|
||||||
libID, libPath := matcher.findLibraryForPath("/music-classical")
|
|
||||||
Expect(libID).To(Equal(2))
|
|
||||||
Expect(libPath).To(Equal("/music-classical"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles complex nested library structures", func() {
|
|
||||||
mockLibRepo.SetData([]model.Library{
|
|
||||||
{ID: 1, Path: "/media"},
|
|
||||||
{ID: 2, Path: "/media/audio"},
|
|
||||||
{ID: 3, Path: "/media/audio/classical"},
|
|
||||||
{ID: 4, Path: "/media/audio/classical/baroque"},
|
|
||||||
})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
path string
|
|
||||||
expectedLibID int
|
|
||||||
expectedLibPath string
|
|
||||||
}{
|
|
||||||
{"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
|
|
||||||
{"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
|
|
||||||
{"/media/audio/rock/track.mp3", 2, "/media/audio"},
|
|
||||||
{"/media/video/movie.mp4", 1, "/media"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
libID, libPath := matcher.findLibraryForPath(tc.path)
|
|
||||||
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
|
|
||||||
Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Edge cases", func() {
|
|
||||||
It("handles empty library list", func() {
|
|
||||||
mockLibRepo.SetData([]model.Library{})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
Expect(matcher).ToNot(BeNil())
|
|
||||||
|
|
||||||
// Should not match anything
|
|
||||||
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
|
||||||
Expect(libID).To(Equal(0))
|
|
||||||
Expect(libPath).To(BeEmpty())
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles single library", func() {
|
|
||||||
mockLibRepo.SetData([]model.Library{
|
|
||||||
{ID: 1, Path: "/music"},
|
|
||||||
})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
|
|
||||||
libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
|
|
||||||
Expect(libID).To(Equal(1))
|
|
||||||
Expect(libPath).To(Equal("/music"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles libraries with special characters in paths", func() {
|
|
||||||
mockLibRepo.SetData([]model.Library{
|
|
||||||
{ID: 1, Path: "/music[test]"},
|
|
||||||
{ID: 2, Path: "/music(backup)"},
|
|
||||||
})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
Expect(matcher).ToNot(BeNil())
|
|
||||||
|
|
||||||
// Special characters should match literally
|
|
||||||
libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
|
|
||||||
Expect(libID).To(Equal(1))
|
|
||||||
Expect(libPath).To(Equal("/music[test]"))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
Describe("Path matching order", func() {
|
|
||||||
It("ensures longest paths match first", func() {
|
|
||||||
mockLibRepo.SetData([]model.Library{
|
|
||||||
{ID: 1, Path: "/a"},
|
|
||||||
{ID: 2, Path: "/ab"},
|
|
||||||
{ID: 3, Path: "/abc"},
|
|
||||||
})
|
|
||||||
|
|
||||||
matcher := createMatcher(ds)
|
|
||||||
|
|
||||||
// Verify that longer paths match correctly (not cut off by shorter prefix)
|
|
||||||
testCases := []struct {
|
|
||||||
path string
|
|
||||||
expectedLibID int
|
|
||||||
}{
|
|
||||||
{"/abc/file.mp3", 3},
|
|
||||||
{"/ab/file.mp3", 2},
|
|
||||||
{"/a/file.mp3", 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
libID, _ := matcher.findLibraryForPath(tc.path)
|
|
||||||
Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
var _ = Describe("pathResolver", func() {
|
var _ = Describe("pathResolver", func() {
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
var mockLibRepo *tests.MockLibraryRepo
|
var mockLibRepo *tests.MockLibraryRepo
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import (
|
||||||
"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/model/request"
|
||||||
"github.com/navidrome/navidrome/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Playlists interface {
|
type Playlists interface {
|
||||||
|
|
@ -78,7 +77,7 @@ func InPath(folder model.Folder) bool {
|
||||||
if conf.Server.PlaylistsPath == "" {
|
if conf.Server.PlaylistsPath == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
rel, _ := utils.RelPath(folder.LibraryPath, folder.AbsolutePath())
|
rel, _ := filepath.Rel(folder.LibraryPath, folder.AbsolutePath())
|
||||||
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
for path := range strings.SplitSeq(conf.Server.PlaylistsPath, string(filepath.ListSeparator)) {
|
||||||
if match, _ := doublestar.Match(path, rel); match {
|
if match, _ := doublestar.Match(path, rel); match {
|
||||||
return true
|
return true
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,17 @@ func (lfs *localFS) ResolveSymlink(name string) (string, error) {
|
||||||
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
|
return filepath.EvalSymlinks(filepath.Join(lfs.root, filepath.FromSlash(name)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReadLink and Lstat implement fs.ReadLinkFS, so callers can detect symlinks without following them.
|
||||||
|
var _ fs.ReadLinkFS = (*localFS)(nil)
|
||||||
|
|
||||||
|
func (lfs *localFS) ReadLink(name string) (string, error) {
|
||||||
|
return fs.ReadLink(lfs.FS, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lfs *localFS) Lstat(name string) (fs.FileInfo, error) {
|
||||||
|
return fs.Lstat(lfs.FS, name)
|
||||||
|
}
|
||||||
|
|
||||||
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
|
func (lfs *localFS) ReadTags(path ...string) (map[string]metadata.Info, error) {
|
||||||
res, err := lfs.extractor.Parse(path...)
|
res, err := lfs.extractor.Parse(path...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
135
db/backup.go
135
db/backup.go
|
|
@ -33,59 +33,41 @@ func backupPath(t time.Time) string {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
|
func openBackupFile(path string) (*sql.DB, error) {
|
||||||
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
|
Db() // Registers Driver, which the CLI commands haven't done at this point.
|
||||||
existingConn, err := Db().Conn(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("getting existing connection: %w", err)
|
|
||||||
}
|
|
||||||
defer existingConn.Close()
|
|
||||||
|
|
||||||
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
|
|
||||||
// path would create an empty database and "restore" it over the live one.
|
|
||||||
if !isBackup {
|
|
||||||
// The driver splits the DSN at '?', so such a path would open a different file.
|
|
||||||
if strings.ContainsRune(path, '?') {
|
|
||||||
return fmt.Errorf("backup path cannot contain '?': %s", path)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
return fmt.Errorf("backup file not available: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
backupDb, err := sql.Open(Driver, path)
|
backupDb, err := sql.Open(Driver, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("opening backup database in '%s': %w", path, err)
|
return nil, fmt.Errorf("opening backup database in '%s': %w", path, err)
|
||||||
}
|
}
|
||||||
defer backupDb.Close()
|
return backupDb, nil
|
||||||
|
}
|
||||||
|
|
||||||
backupConn, err := backupDb.Conn(ctx)
|
func copyDatabase(ctx context.Context, dest, source *sql.DB) error {
|
||||||
|
// heavily inspired by https://codingrabbits.dev/posts/go_and_sqlite_backup_and_maybe_restore/
|
||||||
|
destConn, err := dest.Conn(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting backup connection: %w", err)
|
return fmt.Errorf("getting destination connection: %w", err)
|
||||||
}
|
}
|
||||||
defer backupConn.Close()
|
defer destConn.Close()
|
||||||
|
|
||||||
err = existingConn.Raw(func(existing any) error {
|
sourceConn, err := source.Conn(ctx)
|
||||||
return backupConn.Raw(func(backup any) error {
|
if err != nil {
|
||||||
var sourceOk, destOk bool
|
return fmt.Errorf("getting source connection: %w", err)
|
||||||
var sourceConn, destConn *sqlite3.SQLiteConn
|
|
||||||
|
|
||||||
if isBackup {
|
|
||||||
sourceConn, sourceOk = existing.(*sqlite3.SQLiteConn)
|
|
||||||
destConn, destOk = backup.(*sqlite3.SQLiteConn)
|
|
||||||
} else {
|
|
||||||
sourceConn, sourceOk = backup.(*sqlite3.SQLiteConn)
|
|
||||||
destConn, destOk = existing.(*sqlite3.SQLiteConn)
|
|
||||||
}
|
}
|
||||||
|
defer sourceConn.Close()
|
||||||
|
|
||||||
if !sourceOk {
|
return destConn.Raw(func(rawDest any) error {
|
||||||
return fmt.Errorf("error trying to convert source to sqlite connection")
|
return sourceConn.Raw(func(rawSource any) error {
|
||||||
}
|
sqliteDest, destOk := rawDest.(*sqlite3.SQLiteConn)
|
||||||
if !destOk {
|
if !destOk {
|
||||||
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
return fmt.Errorf("error trying to convert destination to sqlite connection")
|
||||||
}
|
}
|
||||||
|
sqliteSource, sourceOk := rawSource.(*sqlite3.SQLiteConn)
|
||||||
|
if !sourceOk {
|
||||||
|
return fmt.Errorf("error trying to convert source to sqlite connection")
|
||||||
|
}
|
||||||
|
|
||||||
backupOp, err := destConn.Backup("main", sourceConn, "main")
|
backupOp, err := sqliteDest.Backup("main", sqliteSource, "main")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error starting sqlite backup: %w", err)
|
return fmt.Errorf("error starting sqlite backup: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -109,24 +91,89 @@ func backupOrRestore(ctx context.Context, isBackup bool, path string) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return err
|
// checkRestoreSource runs sanity checks on a file about to be restored over the live
|
||||||
|
// database. They catch the common ways a backup goes bad, not every kind of damage.
|
||||||
|
func checkRestoreSource(ctx context.Context, backupDb *sql.DB, path string, integrityCheck bool) error {
|
||||||
|
// SQLite opens an empty file as an empty database, and restoring it would wipe the live one.
|
||||||
|
// user and media_file exist since the first migration, so backups of any age have them.
|
||||||
|
var tables int
|
||||||
|
err := backupDb.QueryRowContext(ctx,
|
||||||
|
"SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('goose_db_version', 'user', 'media_file')").Scan(&tables)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading backup file '%s': %w", path, err)
|
||||||
|
}
|
||||||
|
if tables != 3 {
|
||||||
|
return fmt.Errorf("backup file '%s' is not a Navidrome database", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The table can be there with nothing in it, or with only the row goose adds when it creates it.
|
||||||
|
var applied int
|
||||||
|
err = backupDb.QueryRowContext(ctx,
|
||||||
|
"SELECT count(*) FROM goose_db_version WHERE is_applied = 1 AND version_id > 0").Scan(&applied)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading backup file '%s': %w", path, err)
|
||||||
|
}
|
||||||
|
if applied == 0 {
|
||||||
|
return fmt.Errorf("backup file '%s' has no applied migrations", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The queries above only read the first pages. Both checks walk the whole file, and
|
||||||
|
// integrity_check also compares indexes to their tables, which is much slower.
|
||||||
|
check := "PRAGMA quick_check(1)"
|
||||||
|
if integrityCheck {
|
||||||
|
check = "PRAGMA integrity_check(1)"
|
||||||
|
}
|
||||||
|
var result string
|
||||||
|
if err := backupDb.QueryRowContext(ctx, check).Scan(&result); err != nil {
|
||||||
|
return fmt.Errorf("checking backup file '%s': %w", path, err)
|
||||||
|
}
|
||||||
|
if result != "ok" {
|
||||||
|
return fmt.Errorf("backup file '%s' is damaged: %s", path, result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Backup(ctx context.Context) (string, error) {
|
func Backup(ctx context.Context) (string, error) {
|
||||||
destPath := backupPath(time.Now())
|
destPath := backupPath(time.Now())
|
||||||
log.Debug(ctx, "Creating backup", "path", destPath)
|
log.Debug(ctx, "Creating backup", "path", destPath)
|
||||||
err := backupOrRestore(ctx, true, destPath)
|
backupDb, err := openBackupFile(destPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
defer backupDb.Close()
|
||||||
|
|
||||||
|
if err := copyDatabase(ctx, backupDb, Db()); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return destPath, nil
|
return destPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Restore(ctx context.Context, path string) error {
|
func Restore(ctx context.Context, path string, integrityCheck bool) error {
|
||||||
log.Debug(ctx, "Restoring backup", "path", path)
|
log.Debug(ctx, "Restoring backup", "path", path)
|
||||||
return backupOrRestore(ctx, false, path)
|
|
||||||
|
// The driver splits the DSN at '?', so such a path would open a different file.
|
||||||
|
if strings.ContainsRune(path, '?') {
|
||||||
|
return fmt.Errorf("backup path cannot contain '?': %s", path)
|
||||||
|
}
|
||||||
|
// The driver opens with SQLITE_OPEN_CREATE, so without this check a typo in the
|
||||||
|
// path would create an empty database and "restore" it over the live one.
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("backup file not available: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backupDb, err := openBackupFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer backupDb.Close()
|
||||||
|
|
||||||
|
if err := checkRestoreSource(ctx, backupDb, path, integrityCheck); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return copyDatabase(ctx, Db(), backupDb)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Prune(ctx context.Context) (int, error) {
|
func Prune(ctx context.Context) (int, error) {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,22 @@ func shortTime(year int, month time.Month, day, hour, minute int) time.Time {
|
||||||
return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
|
return time.Date(year, month, day, hour, minute, 0, 0, time.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
gooseTableDDL = "CREATE TABLE goose_db_version(version_id, is_applied)"
|
||||||
|
navidromeTablesDDL = "CREATE TABLE user(id); CREATE TABLE media_file(id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sqliteFile(statements string) func(path string) {
|
||||||
|
return func(path string) {
|
||||||
|
GinkgoHelper()
|
||||||
|
other, err := sql.Open(Driver, path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
defer other.Close()
|
||||||
|
_, err = other.Exec(statements)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var _ = Describe("database backups", func() {
|
var _ = Describe("database backups", func() {
|
||||||
When("there are a few backup files", func() {
|
When("there are a few backup files", func() {
|
||||||
var ctx context.Context
|
var ctx context.Context
|
||||||
|
|
@ -158,7 +174,7 @@ var _ = Describe("database backups", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue())
|
Expect(IsSchemaEmpty(ctx, Db())).To(BeTrue())
|
||||||
|
|
||||||
err = Restore(ctx, path)
|
err = Restore(ctx, path, false)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
|
Expect(IsSchemaEmpty(ctx, Db())).To(BeFalse())
|
||||||
})
|
})
|
||||||
|
|
@ -176,6 +192,7 @@ var _ = Describe("database backups", func() {
|
||||||
var err error
|
var err error
|
||||||
tempFolder, err = os.MkdirTemp("", "navidrome_restore")
|
tempFolder, err = os.MkdirTemp("", "navidrome_restore")
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
conf.Server.Backup.Path = conf.NewDir(tempFolder)
|
||||||
DeferCleanup(func() {
|
DeferCleanup(func() {
|
||||||
Close(ctx)
|
Close(ctx)
|
||||||
_ = os.RemoveAll(tempFolder)
|
_ = os.RemoveAll(tempFolder)
|
||||||
|
|
@ -209,7 +226,7 @@ var _ = Describe("database backups", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
By("restoring the backup")
|
By("restoring the backup")
|
||||||
Expect(Restore(ctx, path)).To(Succeed())
|
Expect(Restore(ctx, path, false)).To(Succeed())
|
||||||
|
|
||||||
By("verifying the restored data is readable through a fresh connection")
|
By("verifying the restored data is readable through a fresh connection")
|
||||||
singleton.DeleteInstance[*sql.DB]()
|
singleton.DeleteInstance[*sql.DB]()
|
||||||
|
|
@ -218,23 +235,91 @@ var _ = Describe("database backups", func() {
|
||||||
Expect(userName).To(Equal("drilladmin"))
|
Expect(userName).To(Equal("drilladmin"))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
|
expectRestoreRefused := func(path, expectedErr string) {
|
||||||
|
GinkgoHelper()
|
||||||
By("seeding user data in the current database")
|
By("seeding user data in the current database")
|
||||||
_, err := Db().ExecContext(ctx, `INSERT INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
|
_, err := Db().ExecContext(ctx, `INSERT OR REPLACE INTO user (id, user_name, name, email, password, is_admin, created_at, updated_at)
|
||||||
VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
|
VALUES ('u-restore-2', 'keepme', 'keepme', 'keepme@example.com', 'x', 1, datetime('now'), datetime('now'))`)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
By("attempting a restore from a nonexistent file")
|
By("attempting the restore")
|
||||||
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
|
Expect(Restore(ctx, path, false)).To(MatchError(ContainSubstring(expectedErr)))
|
||||||
err = Restore(ctx, missingPath)
|
|
||||||
Expect(err).To(HaveOccurred())
|
|
||||||
|
|
||||||
By("verifying the database was not wiped")
|
By("verifying the database was not wiped")
|
||||||
var userName string
|
var userName string
|
||||||
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
|
Expect(Db().QueryRowContext(ctx, "SELECT user_name FROM user WHERE id = 'u-restore-2'").Scan(&userName)).To(Succeed())
|
||||||
Expect(userName).To(Equal("keepme"))
|
Expect(userName).To(Equal("keepme"))
|
||||||
|
}
|
||||||
|
|
||||||
|
It("fails to restore from a backup file that does not exist, leaving the database intact", func() {
|
||||||
|
missingPath := filepath.Join(tempFolder, "does_not_exist.db")
|
||||||
|
expectRestoreRefused(missingPath, "backup file not available")
|
||||||
_, statErr := os.Stat(missingPath)
|
_, statErr := os.Stat(missingPath)
|
||||||
Expect(statErr).To(MatchError(os.ErrNotExist))
|
Expect(statErr).To(MatchError(os.ErrNotExist))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DescribeTable("fails to restore from a file that is not a Navidrome database, leaving the database intact",
|
||||||
|
func(createFile func(path string), expectedErr string) {
|
||||||
|
invalidPath := filepath.Join(GinkgoT().TempDir(), "invalid_backup.db")
|
||||||
|
createFile(invalidPath)
|
||||||
|
expectRestoreRefused(invalidPath, expectedErr)
|
||||||
|
},
|
||||||
|
Entry("empty file", func(path string) {
|
||||||
|
Expect(os.WriteFile(path, nil, 0600)).To(Succeed())
|
||||||
|
}, "is not a Navidrome database"),
|
||||||
|
Entry("file that is not a SQLite database", func(path string) {
|
||||||
|
Expect(os.WriteFile(path, []byte("this is not a database, just some text"), 0600)).To(Succeed())
|
||||||
|
}, "reading backup file"),
|
||||||
|
Entry("SQLite database without the Navidrome schema",
|
||||||
|
sqliteFile("CREATE TABLE something_else(x)"), "is not a Navidrome database"),
|
||||||
|
Entry("another project's goose database",
|
||||||
|
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1)"), "is not a Navidrome database"),
|
||||||
|
Entry("goose database with only one of the Navidrome tables",
|
||||||
|
sqliteFile(gooseTableDDL+"; INSERT INTO goose_db_version VALUES (1, 1); CREATE TABLE media_file(id)"), "is not a Navidrome database"),
|
||||||
|
Entry("SQLite database with an empty goose_db_version table",
|
||||||
|
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL), "has no applied migrations"),
|
||||||
|
Entry("SQLite database with only goose's initial version row",
|
||||||
|
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (0, 1)"), "has no applied migrations"),
|
||||||
|
Entry("SQLite database whose migrations were all rolled back",
|
||||||
|
sqliteFile(gooseTableDDL+"; "+navidromeTablesDDL+"; INSERT INTO goose_db_version VALUES (1, 0)"), "has no applied migrations"),
|
||||||
|
Entry("backup with garbage over its last pages", func(path string) {
|
||||||
|
// Filler rows go to the end of the file, so the damage below stays clear of
|
||||||
|
// the schema and of goose_db_version, which still read fine.
|
||||||
|
_, err := Db().ExecContext(ctx, "CREATE TABLE _restore_filler AS SELECT zeroblob(1000000) AS x")
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
backupFile, err := Backup(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
data, err := os.ReadFile(backupFile)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
for i := len(data) * 4 / 5; i < len(data); i++ {
|
||||||
|
data[i] = 0xFF
|
||||||
|
}
|
||||||
|
Expect(os.WriteFile(path, data, 0600)).To(Succeed())
|
||||||
|
}, "is damaged"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("restores a good backup with the integrity check", func() {
|
||||||
|
path, err := Backup(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(Restore(ctx, path, true)).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("refuses an index that doesn't match its table only with the integrity check", func() {
|
||||||
|
path, err := Backup(ctx)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
writeMismatchedIndex(ctx, path, 1)
|
||||||
|
restored := func() int {
|
||||||
|
var count int
|
||||||
|
Expect(Db().QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE name = 't'").Scan(&count)).To(Succeed())
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
Expect(Restore(ctx, path, true)).To(MatchError(ContainSubstring("is damaged")))
|
||||||
|
Expect(restored()).To(BeZero())
|
||||||
|
|
||||||
|
Expect(Restore(ctx, path, false)).To(Succeed())
|
||||||
|
Expect(restored()).To(Equal(1))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
18
db/migrations/20260929221042_add_library_pid_columns.sql
Normal file
18
db/migrations/20260929221042_add_library_pid_columns.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
alter table library add column pid_album varchar default '' not null;
|
||||||
|
alter table library add column pid_track varchar default '' not null;
|
||||||
|
alter table library add column scanned_pid_album varchar default '' not null;
|
||||||
|
alter table library add column scanned_pid_track varchar default '' not null;
|
||||||
|
|
||||||
|
-- Every library was scanned with the global PID config, so seed it as their scanned config.
|
||||||
|
-- This way the upgrade does not trigger a full rescan.
|
||||||
|
update library set
|
||||||
|
scanned_pid_album = coalesce((select value from property where id = 'PIDAlbum'), ''),
|
||||||
|
scanned_pid_track = coalesce((select value from property where id = 'PIDTrack'), '');
|
||||||
|
|
||||||
|
delete from property where id in ('PIDAlbum', 'PIDTrack');
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
SELECT 1;
|
||||||
|
|
@ -38,18 +38,13 @@ func newDB(ctx context.Context, upTo int64) *sql.DB {
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
||||||
// openMismatchedIndexDB builds a database whose index is declared over a different
|
// writeMismatchedIndex adds an index declared over a different column than the one it
|
||||||
// column than the one it was populated from, so integrity_check reports one issue per row.
|
// was populated from, so integrity_check reports one issue per row and quick_check none.
|
||||||
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
func writeMismatchedIndex(ctx context.Context, path string, rows int) {
|
||||||
GinkgoHelper()
|
GinkgoHelper()
|
||||||
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
|
|
||||||
open := func() *sql.DB {
|
|
||||||
d, err := sql.Open(db.Dialect, path)
|
d, err := sql.Open(db.Dialect, path)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
d.SetMaxOpenConns(1)
|
d.SetMaxOpenConns(1)
|
||||||
return d
|
|
||||||
}
|
|
||||||
d := open()
|
|
||||||
for _, stmt := range []string{
|
for _, stmt := range []string{
|
||||||
`create table t(a, b)`,
|
`create table t(a, b)`,
|
||||||
fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d)
|
fmt.Sprintf(`with recursive s(x) as (select 1 union all select x+1 from s where x < %d)
|
||||||
|
|
@ -62,8 +57,16 @@ func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
}
|
}
|
||||||
Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema
|
Expect(d.Close()).To(Succeed()) // reopen so SQLite reparses the doctored schema
|
||||||
|
}
|
||||||
|
|
||||||
d = open()
|
func openMismatchedIndexDB(ctx context.Context, rows int) *sql.DB {
|
||||||
|
GinkgoHelper()
|
||||||
|
path := filepath.Join(GinkgoT().TempDir(), "mismatched.db")
|
||||||
|
writeMismatchedIndex(ctx, path, rows)
|
||||||
|
|
||||||
|
d, err := sql.Open(db.Dialect, path)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
d.SetMaxOpenConns(1)
|
||||||
DeferCleanup(func() { _ = d.Close() })
|
DeferCleanup(func() { _ = d.Close() })
|
||||||
return d
|
return d
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/deluan/rest"
|
"github.com/deluan/rest"
|
||||||
|
"github.com/navidrome/navidrome/conf"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -27,6 +30,38 @@ type Library struct {
|
||||||
TotalSize int64 `json:"totalSize" db:"total_size"`
|
TotalSize int64 `json:"totalSize" db:"total_size"`
|
||||||
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
|
TotalDuration float64 `json:"totalDuration" db:"total_duration"`
|
||||||
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
|
DefaultNewUsers bool `json:"defaultNewUsers" db:"default_new_users"`
|
||||||
|
PIDAlbum string `json:"pidAlbum" db:"pid_album"`
|
||||||
|
PIDTrack string `json:"pidTrack" db:"pid_track"`
|
||||||
|
ScannedPIDAlbum string `json:"-" db:"scanned_pid_album"`
|
||||||
|
ScannedPIDTrack string `json:"-" db:"scanned_pid_track"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIDConfig holds the persistent ID specs used to compute track and album IDs.
|
||||||
|
type PIDConfig struct {
|
||||||
|
Track string
|
||||||
|
Album string
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectivePID returns the PID specs in effect for this library: its own overrides, falling back to
|
||||||
|
// the global config.
|
||||||
|
func (l Library) EffectivePID() PIDConfig {
|
||||||
|
return PIDConfig{
|
||||||
|
Track: cmp.Or(l.PIDTrack, conf.Server.PID.Track),
|
||||||
|
Album: cmp.Or(l.PIDAlbum, conf.Server.PID.Album),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PIDChanged reports whether the effective PID specs differ from the ones used by the last finished
|
||||||
|
// scan of this library. A library that was never scanned counts as changed.
|
||||||
|
func (l Library) PIDChanged() bool {
|
||||||
|
pid := l.EffectivePID()
|
||||||
|
return !strings.EqualFold(l.ScannedPIDAlbum, pid.Album) || !strings.EqualFold(l.ScannedPIDTrack, pid.Track)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedsPIDRescan reports whether the library has content imported with an old PID config, so it must be
|
||||||
|
// rescanned in full. A library that never finished a scan has nothing to regroup.
|
||||||
|
func (l Library) NeedsPIDRescan() bool {
|
||||||
|
return !l.LastScanAt.IsZero() && l.PIDChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -59,6 +94,8 @@ type LibraryRepository interface {
|
||||||
// TODO These methods should be moved to a core service
|
// TODO These methods should be moved to a core service
|
||||||
ScanBegin(ctx context.Context, id int, fullScan bool) error
|
ScanBegin(ctx context.Context, id int, fullScan bool) error
|
||||||
ScanEnd(ctx context.Context, id int) error
|
ScanEnd(ctx context.Context, id int) error
|
||||||
|
// SetScannedPID records the PID specs used by the last finished scan of the library
|
||||||
|
SetScannedPID(ctx context.Context, id int, pid PIDConfig) error
|
||||||
ScanInProgress(ctx context.Context) (bool, error)
|
ScanInProgress(ctx context.Context) (bool, error)
|
||||||
RefreshStats(ctx context.Context, id int) error
|
RefreshStats(ctx context.Context, id int) error
|
||||||
}
|
}
|
||||||
|
|
|
||||||
57
model/library_matcher.go
Normal file
57
model/library_matcher.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LibraryMatcher finds the library that contains an absolute path.
|
||||||
|
type LibraryMatcher struct {
|
||||||
|
libraries Libraries
|
||||||
|
cleanedPaths []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLibraryMatcher sorts the libraries longest path first, so /music-classical is checked before /music.
|
||||||
|
func NewLibraryMatcher(libs Libraries) *LibraryMatcher {
|
||||||
|
libs = slices.Clone(libs)
|
||||||
|
slices.SortFunc(libs, func(i, j Library) int {
|
||||||
|
return cmp.Compare(len(j.Path), len(i.Path))
|
||||||
|
})
|
||||||
|
cleanedPaths := make([]string, len(libs))
|
||||||
|
for i, lib := range libs {
|
||||||
|
cleanedPaths[i] = filepath.Clean(lib.Path)
|
||||||
|
}
|
||||||
|
return &LibraryMatcher{libraries: libs, cleanedPaths: cleanedPaths}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindLibrary returns the library whose path contains absolutePath.
|
||||||
|
func (lm *LibraryMatcher) FindLibrary(absolutePath string) (Library, bool) {
|
||||||
|
for i, libPath := range lm.cleanedPaths {
|
||||||
|
// A cleaned path only ends with a separator when it is a filesystem root
|
||||||
|
if strings.HasPrefix(absolutePath, libPath) && (len(absolutePath) == len(libPath) ||
|
||||||
|
absolutePath[len(libPath)] == filepath.Separator || strings.HasSuffix(libPath, string(filepath.Separator))) {
|
||||||
|
return lm.libraries[i], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Library{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryRelativePath rebases an absolute path onto the library root, as the scanner's io/fs sees it
|
||||||
|
// (forward slashes). Relative paths, and absolute paths outside the library root, are returned unchanged.
|
||||||
|
func LibraryRelativePath(libPath, path string) string {
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
// The library root may be relative (e.g. the default "./music"); it resolves against the same cwd
|
||||||
|
absLib, err := filepath.Abs(libPath)
|
||||||
|
if err != nil {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(absLib, path)
|
||||||
|
if err != nil || !filepath.IsLocal(rel) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(rel)
|
||||||
|
}
|
||||||
91
model/library_matcher_test.go
Normal file
91
model/library_matcher_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/navidrome/navidrome/model"
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("LibraryMatcher", func() {
|
||||||
|
// Paths are written Unix-style and converted, so they use the OS separator, as filepath.Abs output does
|
||||||
|
find := func(libs model.Libraries, path string) int {
|
||||||
|
for i := range libs {
|
||||||
|
libs[i].Path = filepath.FromSlash(libs[i].Path)
|
||||||
|
}
|
||||||
|
lib, ok := model.NewLibraryMatcher(libs).FindLibrary(filepath.FromSlash(path))
|
||||||
|
if !ok {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return lib.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
DescribeTable("matches the longest library path",
|
||||||
|
func(libs model.Libraries, path string, expectedID int) {
|
||||||
|
Expect(find(libs, path)).To(Equal(expectedID))
|
||||||
|
},
|
||||||
|
Entry("nested library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}, {ID: 3, Path: "/music-classical/opera"}}, "/music-classical/opera/subdir/track.mp3", 3),
|
||||||
|
Entry("sibling with a shared prefix", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical/track.mp3", 2),
|
||||||
|
Entry("shorter library", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music/track.mp3", 1),
|
||||||
|
Entry("exact library root", model.Libraries{{ID: 1, Path: "/music"}, {ID: 2, Path: "/music-classical"}}, "/music-classical", 2),
|
||||||
|
Entry("deeply nested libraries", model.Libraries{{ID: 1, Path: "/media"}, {ID: 2, Path: "/media/audio"}, {ID: 3, Path: "/media/audio/classical"}, {ID: 4, Path: "/media/audio/classical/baroque"}}, "/media/audio/classical/mozart/track.mp3", 3),
|
||||||
|
Entry("prefix that is not a path boundary", model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/ab"}, {ID: 3, Path: "/abc"}}, "/ab/file.mp3", 2),
|
||||||
|
Entry("special characters match literally", model.Libraries{{ID: 1, Path: "/music[test]"}, {ID: 2, Path: "/music(backup)"}}, "/music[test]/track.mp3", 1),
|
||||||
|
Entry("library path with a trailing slash", model.Libraries{{ID: 1, Path: "/music/"}}, "/music/track.mp3", 1),
|
||||||
|
Entry("library at the filesystem root", model.Libraries{{ID: 1, Path: "/"}}, "/music/track.mp3", 1),
|
||||||
|
Entry("nested library under a root library", model.Libraries{{ID: 1, Path: "/"}, {ID: 2, Path: "/music"}}, "/music/track.mp3", 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("does not match a path outside every library", func() {
|
||||||
|
Expect(find(model.Libraries{{ID: 1, Path: "/music"}}, "/music-backup/track.mp3")).To(BeZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not match anything without libraries", func() {
|
||||||
|
Expect(find(nil, "/music/track.mp3")).To(BeZero())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not reorder the caller's libraries", func() {
|
||||||
|
libs := model.Libraries{{ID: 1, Path: "/a"}, {ID: 2, Path: "/abc"}}
|
||||||
|
model.NewLibraryMatcher(libs)
|
||||||
|
Expect(libs.IDs()).To(Equal([]int{1, 2}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
var _ = Describe("LibraryRelativePath", func() {
|
||||||
|
// Paths are built with filepath so the "absolute" cases stay absolute on every OS
|
||||||
|
// (a Unix-style "/foo" is not absolute on Windows).
|
||||||
|
libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection"))
|
||||||
|
outside, _ := filepath.Abs(filepath.Join("somewhere", "else"))
|
||||||
|
|
||||||
|
It("returns a relative path unchanged", func() {
|
||||||
|
Expect(model.LibraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rebases an absolute target when the library root is relative", func() {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(model.LibraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rebases an absolute path that equals the library root to '.'", func() {
|
||||||
|
Expect(model.LibraryRelativePath(libRoot, libRoot)).To(Equal("."))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rebases an absolute path under the library root", func() {
|
||||||
|
Expect(model.LibraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("handles a trailing slash on the library path", func() {
|
||||||
|
Expect(model.LibraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves an absolute path outside the library root unchanged", func() {
|
||||||
|
Expect(model.LibraryRelativePath(libRoot, outside)).To(Equal(outside))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns an empty path unchanged", func() {
|
||||||
|
Expect(model.LibraryRelativePath(libRoot, "")).To(Equal(""))
|
||||||
|
})
|
||||||
|
})
|
||||||
73
model/library_test.go
Normal file
73
model/library_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
package model_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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("Library PID config", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
DeferCleanup(configtest.SetupConfig())
|
||||||
|
conf.Server.PID.Album = "global_album"
|
||||||
|
conf.Server.PID.Track = "global_track"
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("EffectivePID", func() {
|
||||||
|
It("falls back to the global config", func() {
|
||||||
|
Expect(model.Library{}.EffectivePID()).To(Equal(model.PIDConfig{Track: "global_track", Album: "global_album"}))
|
||||||
|
})
|
||||||
|
It("uses the library overrides", func() {
|
||||||
|
lib := model.Library{PIDAlbum: "folder", PIDTrack: "title"}
|
||||||
|
Expect(lib.EffectivePID()).To(Equal(model.PIDConfig{Track: "title", Album: "folder"}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("PIDChanged", func() {
|
||||||
|
It("is false when the scanned specs match, ignoring case", func() {
|
||||||
|
lib := model.Library{ScannedPIDAlbum: "GLOBAL_ALBUM", ScannedPIDTrack: "global_track"}
|
||||||
|
Expect(lib.PIDChanged()).To(BeFalse())
|
||||||
|
})
|
||||||
|
It("is true when the album override differs from the scanned spec", func() {
|
||||||
|
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
|
||||||
|
Expect(lib.PIDChanged()).To(BeTrue())
|
||||||
|
})
|
||||||
|
It("is true when only the track spec changed", func() {
|
||||||
|
lib := model.Library{PIDTrack: "title", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track"}
|
||||||
|
Expect(lib.PIDChanged()).To(BeTrue())
|
||||||
|
})
|
||||||
|
It("is true when the global config changed for a library without overrides", func() {
|
||||||
|
lib := model.Library{ScannedPIDAlbum: "old_album", ScannedPIDTrack: "global_track"}
|
||||||
|
Expect(lib.PIDChanged()).To(BeTrue())
|
||||||
|
})
|
||||||
|
It("is true for a library that was never scanned", func() {
|
||||||
|
Expect(model.Library{}.PIDChanged()).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("NeedsPIDRescan", func() {
|
||||||
|
It("is false for a library that never finished a scan", func() {
|
||||||
|
Expect(model.Library{PIDAlbum: "folder"}.NeedsPIDRescan()).To(BeFalse())
|
||||||
|
})
|
||||||
|
It("is true for a scanned library whose PID config changed", func() {
|
||||||
|
lib := model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
|
||||||
|
Expect(lib.NeedsPIDRescan()).To(BeTrue())
|
||||||
|
})
|
||||||
|
It("is false for a scanned library whose PID config did not change", func() {
|
||||||
|
lib := model.Library{ScannedPIDAlbum: "global_album", ScannedPIDTrack: "global_track", LastScanAt: time.Now()}
|
||||||
|
Expect(lib.NeedsPIDRescan()).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not expose the scanned specs in JSON", func() {
|
||||||
|
data, err := json.Marshal(model.Library{PIDAlbum: "folder", ScannedPIDAlbum: "secret_album", ScannedPIDTrack: "secret_track"})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(data)).To(ContainSubstring(`"pidAlbum":"folder"`))
|
||||||
|
Expect(string(data)).ToNot(ContainSubstring("secret_"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -6,17 +6,18 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"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/str"
|
"github.com/navidrome/navidrome/utils/str"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
func (md Metadata) ToMediaFile(lib model.Library, folderID string) model.MediaFile {
|
||||||
mf := model.MediaFile{
|
mf := model.MediaFile{
|
||||||
LibraryID: libID,
|
LibraryID: lib.ID,
|
||||||
FolderID: folderID,
|
FolderID: folderID,
|
||||||
Tags: maps.Clone(md.tags),
|
Tags: maps.Clone(md.tags),
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +44,7 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||||
}
|
}
|
||||||
mf.Lyrics = md.mapLyrics()
|
mf.Lyrics = md.mapLyrics()
|
||||||
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
mf.ExplicitStatus = md.mapExplicitStatusTag()
|
||||||
|
md.mapMovementNumbers(mf.Tags)
|
||||||
|
|
||||||
// Dates
|
// Dates
|
||||||
date, origDate, relDate := md.mapDates()
|
date, origDate, relDate := md.mapDates()
|
||||||
|
|
@ -84,8 +86,9 @@ func (md Metadata) ToMediaFile(libID int, folderID string) model.MediaFile {
|
||||||
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
|
mf.AlbumArtist = md.mapDisplayAlbumArtist(mf)
|
||||||
|
|
||||||
// Persistent IDs
|
// Persistent IDs
|
||||||
mf.PID = md.trackPID(mf)
|
pid := lib.EffectivePID()
|
||||||
mf.AlbumID = md.albumID(mf, conf.Server.PID.Album)
|
mf.PID = md.trackPID(mf, pid)
|
||||||
|
mf.AlbumID = md.albumID(mf, pid.Album)
|
||||||
|
|
||||||
// BFR These IDs will go away once the UI handle multiple participants.
|
// BFR These IDs will go away once the UI handle multiple participants.
|
||||||
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
|
// BFR For Legacy Subsonic compatibility, we will set them in the API handlers
|
||||||
|
|
@ -135,6 +138,27 @@ func (md Metadata) mapGain(rg, r128 model.TagName) *float64 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mapMovementNumbers splits ID3 MVIN "n/N" values, like tuple() does for track and disc numbers.
|
||||||
|
// Totals are only filled when every value has one, so the parallel movement tags stay aligned.
|
||||||
|
func (md Metadata) mapMovementNumbers(tags model.Tags) {
|
||||||
|
values := md.Strings(model.TagMovementNumber)
|
||||||
|
if !slices.ContainsFunc(values, func(v string) bool { return strings.Contains(v, "/") }) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
numbers := make([]string, len(values))
|
||||||
|
totals := make([]string, len(values))
|
||||||
|
allHaveTotals := true
|
||||||
|
for i, v := range values {
|
||||||
|
var found bool
|
||||||
|
numbers[i], totals[i], found = strings.Cut(v, "/")
|
||||||
|
allHaveTotals = allHaveTotals && found
|
||||||
|
}
|
||||||
|
tags[model.TagMovementNumber] = numbers
|
||||||
|
if allHaveTotals && len(md.Strings(model.TagMovementTotal)) == 0 {
|
||||||
|
tags[model.TagMovementTotal] = totals
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (md Metadata) mapLyrics() string {
|
func (md Metadata) mapLyrics() string {
|
||||||
rawLyrics := md.Pairs(model.TagLyrics)
|
rawLyrics := md.Pairs(model.TagLyrics)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,23 @@ var _ = Describe("ToMediaFile", func() {
|
||||||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||||
props.Tags = tags
|
props.Tags = tags
|
||||||
md = metadata.New("filepath", props)
|
md = metadata.New("filepath", props)
|
||||||
return md.ToMediaFile(1, "folderID")
|
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Describe("Persistent IDs", func() {
|
||||||
|
It("uses the library PID config for the album ID and for albumid in the track spec", func() {
|
||||||
|
props.Tags = model.RawTags{"ALBUM": {"Kind of Blue"}, "TITLE": {"So What"}}
|
||||||
|
md = metadata.New("Jazz/Loose/01.mp3", props)
|
||||||
|
|
||||||
|
byTags := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "album", PIDTrack: "albumid,title"}, "folderID")
|
||||||
|
byFolder := md.ToMediaFile(model.Library{ID: 1, PIDAlbum: "folder", PIDTrack: "albumid,title"}, "folderID")
|
||||||
|
|
||||||
|
Expect(byFolder.AlbumID).ToNot(Equal(byTags.AlbumID))
|
||||||
|
Expect(byFolder.AlbumID).To(Equal(md.AlbumID(byFolder, "folder")))
|
||||||
|
Expect(byFolder.PID).ToNot(Equal(byTags.PID))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("Dates", func() {
|
Describe("Dates", func() {
|
||||||
It("should parse properly tagged dates ", func() {
|
It("should parse properly tagged dates ", func() {
|
||||||
mf = toMediaFile(model.RawTags{
|
mf = toMediaFile(model.RawTags{
|
||||||
|
|
@ -142,6 +156,27 @@ var _ = Describe("ToMediaFile", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("Movements", func() {
|
||||||
|
It("splits an ID3 MVIN n/N value into movement number and total", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movementnumber": {"1/4"}})
|
||||||
|
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementNumber, []string{"1"}))
|
||||||
|
Expect(mf.Tags).To(HaveKeyWithValue(model.TagMovementTotal, []string{"4"}))
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 1, Count: 4}}))
|
||||||
|
})
|
||||||
|
It("maps MP4 movement number and count", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"Andante"}, "movementnumber": {"2"}, "movementcount": {"4"}})
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Andante", Number: 2, Count: 4}}))
|
||||||
|
})
|
||||||
|
It("prefers an explicit movement total over the n/N total", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"Allegro"}, "movement": {"2/4"}, "movementtotal": {"5"}})
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "Allegro", Number: 2, Count: 5}}))
|
||||||
|
})
|
||||||
|
It("does not add a total when only some values have one", func() {
|
||||||
|
mf = toMediaFile(model.RawTags{"movementname": {"I", "II"}, "movement": {"1/4", "2"}})
|
||||||
|
Expect(mf.Movements()).To(Equal([]model.Movement{{Name: "I", Number: 1}, {Name: "II", Number: 2}}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("BitDepth", func() {
|
Describe("BitDepth", func() {
|
||||||
It("maps the bit depth when present", func() {
|
It("maps the bit depth when present", func() {
|
||||||
props.AudioProperties = metadata.AudioProperties{BitDepth: 24}
|
props.AudioProperties = metadata.AudioProperties{BitDepth: 24}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ var _ = Describe("Participants", func() {
|
||||||
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
var toMediaFile = func(tags model.RawTags) model.MediaFile {
|
||||||
props.Tags = tags
|
props.Tags = tags
|
||||||
md = metadata.New("filepath", props)
|
md = metadata.New("filepath", props)
|
||||||
return md.ToMediaFile(1, "folderID")
|
return md.ToMediaFile(model.Library{ID: 1}, "folderID")
|
||||||
}
|
}
|
||||||
|
|
||||||
Describe("ARTIST(S) tags", func() {
|
Describe("ARTIST(S) tags", func() {
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,13 @@ var _ = Describe("Metadata", func() {
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("should map the TagLib-normalized initialkey tag to key", func() {
|
||||||
|
props.Tags = model.RawTags{"initialkey": {"Ebm"}}
|
||||||
|
md = metadata.New(filePath, props)
|
||||||
|
|
||||||
|
Expect(md.String(model.TagName("key"))).To(Equal("Ebm"))
|
||||||
|
})
|
||||||
|
|
||||||
It("should clean the tags map correctly", func() {
|
It("should clean the tags map correctly", func() {
|
||||||
const unknownTag = "UNKNOWN_TAG"
|
const unknownTag = "UNKNOWN_TAG"
|
||||||
props.Tags = model.RawTags{
|
props.Tags = model.RawTags{
|
||||||
|
|
@ -323,7 +330,7 @@ var _ = Describe("Metadata", func() {
|
||||||
tag: {tagValue},
|
tag: {tagValue},
|
||||||
}
|
}
|
||||||
md = metadata.New(filePath, props)
|
md = metadata.New(filePath, props)
|
||||||
return md.ToMediaFile(0, "0")
|
return md.ToMediaFile(model.Library{}, "0")
|
||||||
}
|
}
|
||||||
|
|
||||||
DescribeTable("Gain",
|
DescribeTable("Gain",
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,11 @@ package metadata
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
|
@ -22,12 +22,13 @@ type hashFunc = func(...string) string
|
||||||
// attributes. Attributes can be either tags or processed values like folder,
|
// attributes. Attributes can be either tags or processed values like folder,
|
||||||
// albumid, albumartistid, etc. For each field, it gets all its attribute values
|
// albumid, albumartistid, etc. For each field, it gets all its attribute values
|
||||||
// and concatenates them, then hashes the result. If a field is empty, it is
|
// and concatenates them, then hashes the result. If a field is empty, it is
|
||||||
// skipped and the function looks for the next field.
|
// skipped and the function looks for the next field. albumSpec is the album PID
|
||||||
|
// spec used to resolve the `albumid` attribute.
|
||||||
//
|
//
|
||||||
// Taking hash as a parameter (instead of closing over it in a factory) keeps
|
// Taking hash as a parameter (instead of closing over it in a factory) keeps
|
||||||
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
|
// mf on the stack: closing over mf would force the whole ~1KB MediaFile to the
|
||||||
// heap on every call.
|
// heap on every call.
|
||||||
func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool, hash hashFunc) string {
|
func computePID(mf model.MediaFile, md Metadata, spec, albumSpec string, prependLibId bool, hash hashFunc) string {
|
||||||
switch spec {
|
switch spec {
|
||||||
case "track_legacy":
|
case "track_legacy":
|
||||||
return legacyTrackID(mf, prependLibId)
|
return legacyTrackID(mf, prependLibId)
|
||||||
|
|
@ -41,7 +42,7 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool,
|
||||||
values := make([]string, len(attributes))
|
values := make([]string, len(attributes))
|
||||||
hasValue := false
|
hasValue := false
|
||||||
for i, attr := range attributes {
|
for i, attr := range attributes {
|
||||||
v := getPIDAttr(mf, md, attr, prependLibId, spec, hash)
|
v := getPIDAttr(mf, md, attr, prependLibId, spec, albumSpec, hash)
|
||||||
if v != "" {
|
if v != "" {
|
||||||
hasValue = true
|
hasValue = true
|
||||||
}
|
}
|
||||||
|
|
@ -58,15 +59,15 @@ func computePID(mf model.MediaFile, md Metadata, spec string, prependLibId bool,
|
||||||
return hash(pid)
|
return hash(pid)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec string, hash hashFunc) string {
|
func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool, spec, albumSpec string, hash hashFunc) string {
|
||||||
attr = strings.TrimSpace(strings.ToLower(attr))
|
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||||
switch attr {
|
switch attr {
|
||||||
case "albumid":
|
case "albumid":
|
||||||
if spec == conf.Server.PID.Album {
|
if spec == albumSpec {
|
||||||
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
log.Error("Recursive PID definition detected, ignoring `albumid`", "spec", spec)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return computePID(mf, md, conf.Server.PID.Album, prependLibId, hash)
|
return computePID(mf, md, albumSpec, albumSpec, prependLibId, hash)
|
||||||
case "folder":
|
case "folder":
|
||||||
return filepath.Dir(mf.Path)
|
return filepath.Dir(mf.Path)
|
||||||
case "albumartistid":
|
case "albumartistid":
|
||||||
|
|
@ -79,18 +80,50 @@ func getPIDAttr(mf model.MediaFile, md Metadata, attr string, prependLibId bool,
|
||||||
return md.String(model.TagName(attr))
|
return md.String(model.TagName(attr))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (md Metadata) trackPID(mf model.MediaFile) string {
|
// ValidatePIDSpec checks a PID override before it is stored; empty means "use the global config".
|
||||||
return computePID(mf, md, conf.Server.PID.Track, true, id.NewHash)
|
// Aliases resolve to empty at scan time: accepted only in track specs, because the default one uses them.
|
||||||
|
func ValidatePIDSpec(spec string, isAlbum bool) error {
|
||||||
|
switch {
|
||||||
|
case spec == "", isAlbum && spec == "album_legacy", !isAlbum && spec == "track_legacy":
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for field := range strings.SplitSeq(spec, "|") {
|
||||||
|
for attr := range strings.SplitSeq(field, ",") {
|
||||||
|
attr = strings.TrimSpace(strings.ToLower(attr))
|
||||||
|
switch attr {
|
||||||
|
case "":
|
||||||
|
return fmt.Errorf("empty attribute in %q", spec)
|
||||||
|
case "albumid":
|
||||||
|
if isAlbum {
|
||||||
|
return errors.New("albumid cannot be used in an album PID")
|
||||||
|
}
|
||||||
|
case "folder", "albumartistid":
|
||||||
|
default:
|
||||||
|
name, ok := model.CanonicalTagName(attr)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unknown attribute %q", attr)
|
||||||
|
}
|
||||||
|
if isAlbum && string(name) != attr {
|
||||||
|
return fmt.Errorf("use the tag name %q instead of its alias %q", name, attr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (md Metadata) trackPID(mf model.MediaFile, pid model.PIDConfig) string {
|
||||||
|
return computePID(mf, md, pid.Track, pid.Album, true, id.NewHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
|
func (md Metadata) albumID(mf model.MediaFile, pidConf string) string {
|
||||||
return computePID(mf, md, pidConf, true, id.NewHash)
|
return computePID(mf, md, pidConf, pidConf, true, id.NewHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BFR Must be configurable?
|
// BFR Must be configurable?
|
||||||
func (md Metadata) artistID(name string) string {
|
func (md Metadata) artistID(name string) string {
|
||||||
mf := model.MediaFile{AlbumArtist: name}
|
mf := model.MediaFile{AlbumArtist: name}
|
||||||
return computePID(mf, md, "albumartistid", false, id.NewHash)
|
return computePID(mf, md, "albumartistid", "", false, id.NewHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (md Metadata) mapTrackTitle() string {
|
func (md Metadata) mapTrackTitle() string {
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,7 @@ package metadata
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
|
||||||
"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"
|
||||||
|
|
@ -16,13 +15,15 @@ var _ = Describe("getPID", func() {
|
||||||
md Metadata
|
md Metadata
|
||||||
mf model.MediaFile
|
mf model.MediaFile
|
||||||
sum hashFunc
|
sum hashFunc
|
||||||
|
albumSpec string
|
||||||
)
|
)
|
||||||
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
getPID := func(mf model.MediaFile, md Metadata, spec string, prependLibId bool) string {
|
||||||
return computePID(mf, md, spec, prependLibId, sum)
|
return computePID(mf, md, spec, albumSpec, prependLibId, sum)
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
sum = func(s ...string) string { return "(" + strings.Join(s, ",") + ")" }
|
||||||
|
albumSpec = consts.DefaultAlbumPID
|
||||||
})
|
})
|
||||||
|
|
||||||
Context("attributes are tags", func() {
|
Context("attributes are tags", func() {
|
||||||
|
|
@ -66,8 +67,7 @@ var _ = Describe("getPID", func() {
|
||||||
|
|
||||||
Context("calculated attributes", func() {
|
Context("calculated attributes", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
albumSpec = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
||||||
conf.Server.PID.Album = "musicbrainz_albumid|albumartistid,album,albumversion,releasedate"
|
|
||||||
})
|
})
|
||||||
When("field is title", func() {
|
When("field is title", func() {
|
||||||
It("should return the pid", func() {
|
It("should return the pid", func() {
|
||||||
|
|
@ -121,8 +121,8 @@ var _ = Describe("getPID", func() {
|
||||||
When("albumid configuration refers to albumid recursively", func() {
|
When("albumid configuration refers to albumid recursively", func() {
|
||||||
It("should avoid infinite recursion", func() {
|
It("should avoid infinite recursion", func() {
|
||||||
// Reproduce the issue from #4920
|
// Reproduce the issue from #4920
|
||||||
conf.Server.PID.Album = "albumid,album,albumversion,releasedate"
|
albumSpec = "albumid,album,albumversion,releasedate"
|
||||||
spec := conf.Server.PID.Album
|
spec := albumSpec
|
||||||
md.tags = map[model.TagName][]string{
|
md.tags = map[model.TagName][]string{
|
||||||
"album": {"Album Name"},
|
"album": {"Album Name"},
|
||||||
"albumversion": {"Version"},
|
"albumversion": {"Version"},
|
||||||
|
|
@ -205,8 +205,7 @@ var _ = Describe("getPID", func() {
|
||||||
})
|
})
|
||||||
When("prependLibId is true with nested albumid", func() {
|
When("prependLibId is true with nested albumid", func() {
|
||||||
It("should handle nested albumid calls correctly", func() {
|
It("should handle nested albumid calls correctly", func() {
|
||||||
DeferCleanup(configtest.SetupConfig())
|
albumSpec = "album"
|
||||||
conf.Server.PID.Album = "album"
|
|
||||||
spec := "albumid"
|
spec := "albumid"
|
||||||
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
md.tags = map[model.TagName][]string{"album": {"Test Album"}}
|
||||||
mf.AlbumArtist = "Test Artist"
|
mf.AlbumArtist = "Test Artist"
|
||||||
|
|
@ -306,3 +305,34 @@ var _ = Describe("getPID", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var _ = Describe("ValidatePIDSpec", func() {
|
||||||
|
DescribeTable("accepts valid specs",
|
||||||
|
func(spec string, isAlbum bool) {
|
||||||
|
Expect(ValidatePIDSpec(spec, isAlbum)).To(Succeed())
|
||||||
|
},
|
||||||
|
Entry("empty, meaning the global config", "", true),
|
||||||
|
Entry("default album spec", consts.DefaultAlbumPID, true),
|
||||||
|
Entry("default track spec, which uses tag aliases", consts.DefaultTrackPID, false),
|
||||||
|
Entry("folder", "folder", true),
|
||||||
|
Entry("album legacy", "album_legacy", true),
|
||||||
|
Entry("track legacy", "track_legacy", false),
|
||||||
|
Entry("computed attributes", "albumartistid,album|title", true),
|
||||||
|
Entry("albumid in a track spec", "albumid,title", false),
|
||||||
|
Entry("spaces and mixed case", "MusicBrainz_AlbumID | Folder", true),
|
||||||
|
)
|
||||||
|
|
||||||
|
DescribeTable("rejects invalid specs",
|
||||||
|
func(spec string, isAlbum bool, msg string) {
|
||||||
|
Expect(ValidatePIDSpec(spec, isAlbum)).To(MatchError(ContainSubstring(msg)))
|
||||||
|
},
|
||||||
|
Entry("unknown tag", "albmversion", true, `unknown attribute "albmversion"`),
|
||||||
|
Entry("empty field", "album||title", true, "empty attribute"),
|
||||||
|
Entry("empty attribute", "album,,title", true, "empty attribute"),
|
||||||
|
Entry("trailing separator", "album|", true, "empty attribute"),
|
||||||
|
Entry("albumid in an album spec", "albumid,album", true, "albumid"),
|
||||||
|
Entry("tag alias in an album spec", "talb", true, `use the tag name "album" instead of its alias "talb"`),
|
||||||
|
Entry("track legacy in an album spec", "track_legacy", true, `unknown attribute "track_legacy"`),
|
||||||
|
Entry("album legacy in a track spec", "album_legacy", false, `unknown attribute "album_legacy"`),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var ErrAlreadyScanning = errors.New("already scanning")
|
||||||
|
|
||||||
// ScanTarget represents a specific folder within a library to be scanned.
|
// ScanTarget represents a specific folder within a library to be scanned.
|
||||||
// NOTE: This struct is used as a map key, so it should only contain comparable types.
|
// NOTE: This struct is used as a map key, so it should only contain comparable types.
|
||||||
type ScanTarget struct {
|
type ScanTarget struct {
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,28 @@ func TagMappings() map[TagName]TagConf {
|
||||||
return mappings
|
return mappings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CanonicalTagName returns the mapped tag that name is, or is an alias of. Tags are stored under this name.
|
||||||
|
func CanonicalTagName(name string) (TagName, bool) {
|
||||||
|
tagName, ok := tagNameIndex()[TagName(name).ToLower()]
|
||||||
|
return tagName, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// tagNameIndex maps every tag name and alias to its tag name. Names are added last, so they win over aliases
|
||||||
|
// (musicbrainz_trackid is a tag and also an alias of musicbrainz_recordingid).
|
||||||
|
var tagNameIndex = sync.OnceValue(func() map[TagName]TagName {
|
||||||
|
mappings := TagMappings()
|
||||||
|
index := make(map[TagName]TagName, len(mappings))
|
||||||
|
for name, tag := range mappings {
|
||||||
|
for _, alias := range tag.Aliases {
|
||||||
|
index[TagName(alias)] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range mappings {
|
||||||
|
index[name] = name
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
})
|
||||||
|
|
||||||
func TagRolesConf() TagConf {
|
func TagRolesConf() TagConf {
|
||||||
_, cfg := parseMappings()
|
_, cfg := parseMappings()
|
||||||
return cfg.Roles
|
return cfg.Roles
|
||||||
|
|
|
||||||
|
|
@ -192,3 +192,22 @@ var _ = Describe("TagConf", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
var _ = Describe("CanonicalTagName", func() {
|
||||||
|
DescribeTable("resolves tag names and aliases",
|
||||||
|
func(name string, expected TagName) {
|
||||||
|
tagName, ok := CanonicalTagName(name)
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(tagName).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("tag name", "album", TagAlbum),
|
||||||
|
Entry("alias", "talb", TagAlbum),
|
||||||
|
Entry("mixed case alias", "TALB", TagAlbum),
|
||||||
|
Entry("tag name that is also an alias of another tag", "musicbrainz_trackid", TagMusicBrainzTrackID),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("does not resolve an unknown name", func() {
|
||||||
|
_, ok := CanonicalTagName("nosuchtag")
|
||||||
|
Expect(ok).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ var albumFilters = sync.OnceValue(func() map[string]filterFunc {
|
||||||
"recently_played": recentlyPlayedFilter,
|
"recently_played": recentlyPlayedFilter,
|
||||||
"starred": annotationBoolFilter("starred"),
|
"starred": annotationBoolFilter("starred"),
|
||||||
"has_rating": annotationBoolFilter("rating"),
|
"has_rating": annotationBoolFilter("rating"),
|
||||||
|
"played": annotationBoolFilter("play_count"),
|
||||||
"missing": booleanFilter,
|
"missing": booleanFilter,
|
||||||
"genre_id": genreFilter(AlbumGenres),
|
"genre_id": genreFilter(AlbumGenres),
|
||||||
"role_total_id": allRolesFilter,
|
"role_total_id": allRolesFilter,
|
||||||
|
|
|
||||||
|
|
@ -417,6 +417,80 @@ var _ = Describe("AlbumRepository", func() {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("played", func() {
|
||||||
|
var playedAlbum model.Album
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
playedAlbum = model.Album{ID: "played-album", Name: "Played Album", LibraryID: 1, SongCount: 1}
|
||||||
|
Expect(albumRepo.Put(ctx, &playedAlbum)).To(Succeed())
|
||||||
|
Expect(albumRepo.IncPlayCount(ctx, playedAlbum.ID, time.Now())).To(Succeed())
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func() {
|
||||||
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("annotation").Where(squirrel.Eq{"item_id": playedAlbum.ID}))
|
||||||
|
_, _ = albumRepo.executeSQL(ctx, squirrel.Delete("album").Where(squirrel.Eq{"id": playedAlbum.ID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("false includes items without annotations", func() {
|
||||||
|
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
|
||||||
|
Filters: map[string]any{"played": "false"},
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
albums := res
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, a := range albums {
|
||||||
|
if a.ID == albumWithoutAnnotation.ID {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(found).To(BeTrue(), "Album without annotation should be included in played=false filter")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("true excludes items without annotations", func() {
|
||||||
|
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
|
||||||
|
Filters: map[string]any{"played": "true"},
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
albums := res
|
||||||
|
|
||||||
|
for _, a := range albums {
|
||||||
|
Expect(a.ID).ToNot(Equal(albumWithoutAnnotation.ID))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
It("true includes items with play count", func() {
|
||||||
|
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
|
||||||
|
Filters: map[string]any{"played": "true"},
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
albums := res
|
||||||
|
|
||||||
|
var found bool
|
||||||
|
for _, a := range albums {
|
||||||
|
if a.ID == playedAlbum.ID {
|
||||||
|
found = true
|
||||||
|
Expect(a.PlayCount).To(BeNumerically(">", 0))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Expect(found).To(BeTrue(), "Album with play count should be included in played=true filter")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("false excludes items with play count", func() {
|
||||||
|
res, err := albumRepo.ReadAll(ctx, rest.QueryOptions{
|
||||||
|
Filters: map[string]any{"played": "false"},
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
albums := res
|
||||||
|
|
||||||
|
for _, a := range albums {
|
||||||
|
Expect(a.ID).ToNot(Equal(playedAlbum.ID))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Album.PlayCount", func() {
|
Describe("Album.PlayCount", func() {
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ func (r *libraryRepository) Put(ctx context.Context, l *model.Library, colsToUpd
|
||||||
"path": l.Path,
|
"path": l.Path,
|
||||||
"remote_path": l.RemotePath,
|
"remote_path": l.RemotePath,
|
||||||
"default_new_users": l.DefaultNewUsers,
|
"default_new_users": l.DefaultNewUsers,
|
||||||
|
"pid_album": l.PIDAlbum,
|
||||||
|
"pid_track": l.PIDTrack,
|
||||||
}, colsToUpdate...)
|
}, colsToUpdate...)
|
||||||
cols["updated_at"] = l.UpdatedAt
|
cols["updated_at"] = l.UpdatedAt
|
||||||
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
|
sq := Update(r.tableName).SetMap(cols).Where(Eq{"id": l.ID})
|
||||||
|
|
@ -176,6 +178,15 @@ func (r *libraryRepository) ScanEnd(ctx context.Context, id int) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *libraryRepository) SetScannedPID(ctx context.Context, id int, pid model.PIDConfig) error {
|
||||||
|
sq := Update(r.tableName).
|
||||||
|
Set("scanned_pid_album", pid.Album).
|
||||||
|
Set("scanned_pid_track", pid.Track).
|
||||||
|
Where(Eq{"id": id})
|
||||||
|
_, err := r.executeSQL(ctx, sq)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) {
|
func (r *libraryRepository) ScanInProgress(ctx context.Context) (bool, error) {
|
||||||
query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}})
|
query := r.newSelect(ctx).Where(NotEq{"last_scan_started_at": time.Time{}})
|
||||||
count, err := r.count(ctx, query)
|
count, err := r.count(ctx, query)
|
||||||
|
|
|
||||||
|
|
@ -270,6 +270,38 @@ var _ = Describe("LibraryRepository", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("PID config", func() {
|
||||||
|
It("stores the overrides, and Put never touches the scanned specs", func() {
|
||||||
|
lib := &model.Library{Name: "PID Library", Path: "/music/pid", PIDAlbum: "folder", PIDTrack: "title"}
|
||||||
|
Expect(repo.Put(ctx, lib)).To(Succeed())
|
||||||
|
Expect(repo.SetScannedPID(ctx, lib.ID, model.PIDConfig{Album: "folder", Track: "title"})).To(Succeed())
|
||||||
|
|
||||||
|
// An update coming from the REST API has no scanned specs. It must not clear them
|
||||||
|
update := &model.Library{ID: lib.ID, Name: "PID Library", Path: "/music/pid", PIDTrack: "title"}
|
||||||
|
Expect(repo.Put(ctx, update)).To(Succeed())
|
||||||
|
|
||||||
|
saved, err := repo.Get(ctx, lib.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(saved.PIDAlbum).To(BeEmpty())
|
||||||
|
Expect(saved.PIDTrack).To(Equal("title"))
|
||||||
|
Expect(saved.ScannedPIDAlbum).To(Equal("folder"))
|
||||||
|
Expect(saved.ScannedPIDTrack).To(Equal("title"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps the overrides when a partial update does not send them", func() {
|
||||||
|
lib := &model.Library{Name: "Partial", Path: "/music/partial", PIDAlbum: "folder", PIDTrack: "title"}
|
||||||
|
Expect(repo.Put(ctx, lib)).To(Succeed())
|
||||||
|
|
||||||
|
Expect(repo.Put(ctx, &model.Library{ID: lib.ID, Name: "Renamed"}, "name")).To(Succeed())
|
||||||
|
|
||||||
|
saved, err := repo.Get(ctx, lib.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(saved.Name).To(Equal("Renamed"))
|
||||||
|
Expect(saved.PIDAlbum).To(Equal("folder"))
|
||||||
|
Expect(saved.PIDTrack).To(Equal("title"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("Delete", func() {
|
Describe("Delete", func() {
|
||||||
var adminRepo model.LibraryRepository
|
var adminRepo model.LibraryRepository
|
||||||
var artistRepo model.ArtistRepository
|
var artistRepo model.ArtistRepository
|
||||||
|
|
|
||||||
|
|
@ -352,6 +352,15 @@ func GetDBXBuilder() *dbx.DB {
|
||||||
return dbx.NewFromDB(db.Db(), db.Dialect)
|
return dbx.NewFromDB(db.Db(), db.Dialect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storedRows reads playlist_tracks without joining media_file, so it also shows rows pointing to no track.
|
||||||
|
func storedRows(playlistID string) []string {
|
||||||
|
GinkgoHelper()
|
||||||
|
var rows []string
|
||||||
|
Expect(GetDBXBuilder().NewQuery("SELECT id || ':' || media_file_id FROM playlist_tracks WHERE playlist_id = {:id} ORDER BY id").
|
||||||
|
Bind(dbx.Params{"id": playlistID}).Column(&rows)).To(Succeed())
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
// collectCursor takes the cursor's underlying func type so the named cursor types
|
// collectCursor takes the cursor's underlying func type so the named cursor types
|
||||||
// (model.AlbumCursor, ...) infer T.
|
// (model.AlbumCursor, ...) infer T.
|
||||||
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ func (r *playlistRepository) updatePlaylist(ctx context.Context, playlistId stri
|
||||||
}
|
}
|
||||||
|
|
||||||
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
// addTracks is the only path that writes playlist_tracks rows (smart playlists aside), so it owns
|
||||||
// the library check: every caller, including a full replace through Put, goes through it.
|
// the library and existence check: every caller, including a full replace through Put, goes through it.
|
||||||
func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) {
|
func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, startingPos int, mediaFileIds []string) (int, error) {
|
||||||
mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds)
|
mediaFileIds, err := r.keepAccessible(ctx, mediaFileIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -305,12 +305,9 @@ func (r *playlistRepository) addTracks(ctx context.Context, playlistId string, s
|
||||||
return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId})
|
return len(mediaFileIds), r.refreshCounters(ctx, &model.Playlist{ID: playlistId})
|
||||||
}
|
}
|
||||||
|
|
||||||
// keepAccessible drops ids the caller cannot read, preserving order and duplicates. Chunked
|
// keepAccessible drops ids the caller cannot read or that match no track, preserving order and duplicates.
|
||||||
// because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
// Chunked because callers pass unbounded id lists (M3U import), well past SQLITE_MAX_VARIABLE_NUMBER.
|
||||||
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
|
func (r *playlistRepository) keepAccessible(ctx context.Context, mediaFileIds []string) ([]string, error) {
|
||||||
if visible, err := r.visibleLibraryIDs(ctx); err == nil && r.userSeesAllLibraries(ctx, visible) {
|
|
||||||
return mediaFileIds, nil
|
|
||||||
}
|
|
||||||
accessible := make(map[string]struct{}, len(mediaFileIds))
|
accessible := make(map[string]struct{}, len(mediaFileIds))
|
||||||
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
for chunk := range slices.Chunk(slice.Unique(mediaFileIds), 200) {
|
||||||
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
sq := r.applyLibraryFilter(ctx, Select("id").From("media_file").Where(Eq{"id": chunk}), "media_file")
|
||||||
|
|
|
||||||
|
|
@ -469,6 +469,26 @@ var _ = Describe("PlaylistRepository", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("removeOrphans", func() {
|
||||||
|
It("deletes rows pointing to no track and renumbers the rest", func() {
|
||||||
|
pls := model.Playlist{Name: "Orphans", OwnerID: "userid"}
|
||||||
|
Expect(repo.Put(ctx, &pls)).To(Succeed())
|
||||||
|
DeferCleanup(func() { Expect(repo.Delete(ctx, pls.ID)).To(Succeed()) })
|
||||||
|
// Rows as an older version stored them, with an unknown id between real tracks
|
||||||
|
_, err := GetDBXBuilder().NewQuery(`INSERT INTO playlist_tracks (playlist_id, media_file_id, id)
|
||||||
|
VALUES ({:id}, '1001', 1), ({:id}, 'no-such-track', 2), ({:id}, '1002', 3)`).
|
||||||
|
Bind(dbx.Params{"id": pls.ID}).Execute()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(repo.(*playlistRepository).removeOrphans(ctx)).To(Succeed())
|
||||||
|
|
||||||
|
Expect(storedRows(pls.ID)).To(Equal([]string{"1:1001", "2:1002"}))
|
||||||
|
got, err := repo.Get(ctx, pls.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(got.SongCount).To(Equal(2))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
|
// Exists is ctx-sensitive through userFilter, so callers that only want "does it still exist"
|
||||||
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
|
// -- the public image route serving a share -- must elevate, or a private playlist looks gone.
|
||||||
Describe("Exists visibility", func() {
|
Describe("Exists visibility", func() {
|
||||||
|
|
|
||||||
|
|
@ -213,7 +213,7 @@ func (r *playlistTrackRepository) Insert(ctx context.Context, mediaFileIds []str
|
||||||
if err != nil || inserted == n {
|
if err != nil || inserted == n {
|
||||||
return inserted, err
|
return inserted, err
|
||||||
}
|
}
|
||||||
// The shift above reserved a slot per requested id, so ids dropped by the library filter
|
// The shift above reserved a slot per requested id, so ids dropped by keepAccessible
|
||||||
// leave a hole. Close it.
|
// leave a hole. Close it.
|
||||||
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
|
return inserted, r.playlistRepo.renumber(ctx, r.playlistId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,14 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
||||||
|
|
||||||
Describe("Insert", func() {
|
Describe("Insert", func() {
|
||||||
var tracks model.PlaylistTrackRepository
|
var tracks model.PlaylistTrackRepository
|
||||||
|
var plsID string
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
plsRepo := NewPlaylistRepository(GetDBXBuilder())
|
plsRepo := NewPlaylistRepository(GetDBXBuilder())
|
||||||
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
|
pls := model.Playlist{Name: "Insert", OwnerID: "userid", OwnerName: "userid"}
|
||||||
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
|
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
|
||||||
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
|
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
|
||||||
|
plsID = pls.ID
|
||||||
|
|
||||||
tracks = plsRepo.Tracks(ctx, pls.ID, false)
|
tracks = plsRepo.Tracks(ctx, pls.ID, false)
|
||||||
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
|
Expect(tracks.Add(ctx, []string{songDayInALife.ID, songRadioactivity.ID})).To(Equal(2))
|
||||||
|
|
@ -123,6 +125,40 @@ var _ = Describe("PlaylistTrackRepository", func() {
|
||||||
Expect(err).ToNot(HaveOccurred())
|
Expect(err).ToNot(HaveOccurred())
|
||||||
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
|
Expect([]string{all[0].ID, all[1].ID, all[2].ID}).To(Equal([]string{"1", "2", "3"}))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("drops ids that match no track, keeping positions contiguous", func() {
|
||||||
|
Expect(tracks.Insert(ctx, []string{"no-such-track", songComeTogether.ID}, 1)).To(Equal(1))
|
||||||
|
|
||||||
|
Expect(storedRows(plsID)).To(Equal([]string{
|
||||||
|
"1:" + songComeTogether.ID, "2:" + songDayInALife.ID, "3:" + songRadioactivity.ID,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("Add", func() {
|
||||||
|
var tracks model.PlaylistTrackRepository
|
||||||
|
var plsID string
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
plsRepo := NewPlaylistRepository(GetDBXBuilder())
|
||||||
|
pls := model.Playlist{Name: "Add", OwnerID: "userid", OwnerName: "userid"}
|
||||||
|
Expect(plsRepo.Put(ctx, &pls)).To(Succeed())
|
||||||
|
DeferCleanup(func() { Expect(plsRepo.Delete(ctx, pls.ID)).To(Succeed()) })
|
||||||
|
plsID = pls.ID
|
||||||
|
tracks = plsRepo.Tracks(ctx, pls.ID, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
It("drops ids that match no track and counts only the tracks added", func() {
|
||||||
|
Expect(tracks.Add(ctx, []string{songDayInALife.ID, "no-such-track", songComeTogether.ID, "!!not-an-id!!"})).To(Equal(2))
|
||||||
|
|
||||||
|
Expect(storedRows(plsID)).To(Equal([]string{"1:" + songDayInALife.ID, "2:" + songComeTogether.ID}))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("adds nothing when no id matches a track", func() {
|
||||||
|
Expect(tracks.Add(ctx, []string{"no-such-track", "!!not-an-id!!"})).To(BeZero())
|
||||||
|
|
||||||
|
Expect(storedRows(plsID)).To(BeEmpty())
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Reorder", func() {
|
Describe("Reorder", func() {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ var _ = Describe("Annotation Filters", func() {
|
||||||
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
|
Entry("starred=false", "starred", "false", "COALESCE(starred, 0) = 0", []any(nil)),
|
||||||
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
|
Entry("starred=True (case insensitive)", "starred", "True", "COALESCE(starred, 0) > 0", []any(nil)),
|
||||||
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
|
Entry("rating=true", "rating", "true", "COALESCE(rating, 0) > 0", []any(nil)),
|
||||||
|
Entry("play_count=true", "play_count", "true", "COALESCE(play_count, 0) > 0", []any(nil)),
|
||||||
|
Entry("play_count=false", "play_count", "false", "COALESCE(play_count, 0) = 0", []any(nil)),
|
||||||
)
|
)
|
||||||
|
|
||||||
It("returns nil if value is not a string", func() {
|
It("returns nil if value is not a string", func() {
|
||||||
|
|
|
||||||
77
resources/hosting/elfhosted.svg
Normal file
77
resources/hosting/elfhosted.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 35 KiB |
25
resources/hosting/pikapods.svg
Normal file
25
resources/hosting/pikapods.svg
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:ns1="http://www.bohemiancoding.com/sketch/ns" width="200" height="40" viewBox="0 0 200 40" role="img" aria-labelledby="badge-title"><!-- Artwork source: https://www.pikapods.com/static/run-button.svg; preserved and centered without distortion. --><title id="badge-title">Run on PikaPods</title><rect width="200" height="40" rx="6" fill="#43A047" /><svg viewBox="0 0 147 32" style="enable-background:new 0 0 147 32;" xml:space="preserve" x="8.12500000" y="0" width="183.75000000" height="40" preserveAspectRatio="xMidYMid meet">
|
||||||
|
<style type="text/css">
|
||||||
|
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:#43A047;}
|
||||||
|
.st1{fill:#FFFFFF;}
|
||||||
|
.st2{opacity:0.6;fill:#FFFFFF;}
|
||||||
|
</style>
|
||||||
|
<title>Run on PikaPods Button</title>
|
||||||
|
<path id="Background" ns1:type="MSShapeGroup" class="st0" d="M4,0h139c2.2,0,4,1.8,4,4v24c0,2.2-1.8,4-4,4H4c-2.2,0-4-1.8-4-4V4 C0,1.8,1.8,0,4,0z" />
|
||||||
|
<path class="st1" d="M44.3,23.3c-0.3,0.6-0.8,0.8-1.4,0.8c-10,0-20,0-30,0c-2.6,0-4.8-1.7-5.4-4.3c-0.7-3,1.4-6,4.4-6.5 c0.3,0,0.5-0.1,0.8-0.1c0.7,0,1.2-0.5,1.2-1.1c0-0.6-0.5-1.1-1.2-1.1c-1.4,0-2.7,0-4.1,0c-0.7,0-1.2-0.4-1.2-1.1 C7.4,9.5,7.9,9,8.6,9c1.4,0,2.8,0,4.3,0c1.8,0,3.2,1.4,3.2,3.2c0,1.8-1.4,3.3-3.2,3.3c-1.8,0-3.1,1.2-3.3,2.9 c-0.2,1.5,0.8,3.4,2.9,3.5c1.2,0.1,2.3,0,3.5,0c0.5-4.5,2.5-8,6.3-10.6c2.2-1.5,4.6-2.2,7.1-2.4c-0.1,0.5-0.3,1-0.3,1.5 c-0.4,2.9,1.8,5.6,4.7,6c0.3,0,0.5,0.1,0.8,0c0.6-0.1,1-0.6,1-1.1c0-0.6-0.5-1-1.1-1c-1.3-0.1-2.3-0.7-2.9-1.8 c-0.6-1.2-0.4-2.6,0.6-3.7C33,8,34.3,7.6,35.5,8c1.2,0.4,2.1,1.6,2.2,2.9c0,0.1,0.1,0.3,0.2,0.4c3.2,2.2,5.2,5.2,6,9 c0.1,0.5,0.2,1.1,0.3,1.6C44.3,22.4,44.3,22.9,44.3,23.3z M38.8,17.7c0-0.6-0.5-1.1-1.1-1.1c-0.6,0-1.1,0.5-1.1,1.1 c0,0.6,0.5,1.1,1.1,1.1C38.3,18.7,38.8,18.2,38.8,17.7z" />
|
||||||
|
<g>
|
||||||
|
<path class="st1" d="M49.7,20.2v-8.6h3.7c0.9,0,1.6,0.1,2,0.2s0.7,0.4,1,0.8s0.4,0.8,0.4,1.4c0,0.6-0.2,1.2-0.6,1.6 s-0.9,0.7-1.7,0.8c0.4,0.2,0.7,0.5,0.9,0.7s0.6,0.7,1,1.4l1,1.7h-2.1L54,18.4c-0.4-0.7-0.8-1.1-0.9-1.3s-0.3-0.3-0.5-0.4 s-0.5-0.1-0.9-0.1h-0.4v3.6H49.7z M51.4,15.3h1.3c0.8,0,1.4,0,1.6-0.1s0.4-0.2,0.5-0.4s0.2-0.4,0.2-0.6c0-0.3-0.1-0.5-0.2-0.7 s-0.4-0.3-0.7-0.3c-0.1,0-0.6,0-1.3,0h-1.4V15.3z" />
|
||||||
|
<path class="st1" d="M62.4,20.2v-0.9c-0.2,0.3-0.5,0.6-0.9,0.8s-0.8,0.3-1.2,0.3c-0.4,0-0.8-0.1-1.1-0.3s-0.6-0.4-0.7-0.8 s-0.2-0.8-0.2-1.4V14h1.6v2.9c0,0.9,0,1.4,0.1,1.6s0.2,0.4,0.3,0.5s0.4,0.2,0.6,0.2c0.3,0,0.5-0.1,0.8-0.2s0.4-0.3,0.5-0.6 c0.1-0.2,0.1-0.8,0.1-1.7V14h1.6v6.2H62.4z" />
|
||||||
|
<path class="st1" d="M71.3,20.2h-1.6V17c0-0.7,0-1.1-0.1-1.3s-0.2-0.4-0.3-0.5s-0.3-0.2-0.6-0.2c-0.3,0-0.5,0.1-0.8,0.2 s-0.4,0.4-0.5,0.6s-0.1,0.7-0.1,1.4v2.8h-1.6V14h1.5v0.9c0.5-0.7,1.2-1.1,2.1-1.1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5 s0.3,0.4,0.3,0.7s0.1,0.6,0.1,1.1V20.2z" />
|
||||||
|
<path class="st2" d="M75.8,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S75.8,18.2,75.8,17.1z M76.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S76.9,16.3,76.9,17.1z" />
|
||||||
|
<path class="st2" d="M82.9,20.2V14h0.9v0.9c0.5-0.7,1.1-1,2-1c0.4,0,0.7,0.1,1,0.2s0.5,0.3,0.7,0.5s0.3,0.5,0.3,0.8 c0,0.2,0.1,0.5,0.1,1v3.8h-1.1v-3.8c0-0.4,0-0.8-0.1-1s-0.2-0.4-0.4-0.5s-0.5-0.2-0.7-0.2c-0.4,0-0.8,0.1-1.2,0.4S84,16,84,16.8 v3.4H82.9z" />
|
||||||
|
<path class="st2" d="M93,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H93z M94.2,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
|
||||||
|
<path class="st2" d="M100.9,12.8v-1.2h1.1v1.2H100.9z M100.9,20.2V14h1.1v6.2H100.9z" />
|
||||||
|
<path class="st2" d="M103.6,20.2v-8.6h1.1v4.9l2.5-2.5h1.4l-2.4,2.3l2.6,3.9h-1.3l-2.1-3.2l-0.7,0.7v2.5H103.6z" />
|
||||||
|
<path class="st2" d="M113.6,19.5c-0.4,0.3-0.8,0.6-1.1,0.7s-0.7,0.2-1.2,0.2c-0.7,0-1.2-0.2-1.6-0.5s-0.6-0.8-0.6-1.3 c0-0.3,0.1-0.6,0.2-0.8s0.3-0.5,0.5-0.6s0.5-0.3,0.8-0.3c0.2-0.1,0.5-0.1,0.9-0.2c0.9-0.1,1.5-0.2,1.9-0.4c0-0.1,0-0.2,0-0.3 c0-0.4-0.1-0.7-0.3-0.9c-0.3-0.2-0.7-0.4-1.2-0.4c-0.5,0-0.9,0.1-1.1,0.3s-0.4,0.5-0.5,0.9l-1-0.1c0.1-0.4,0.2-0.8,0.5-1.1 s0.5-0.5,0.9-0.6s0.9-0.2,1.4-0.2c0.5,0,1,0.1,1.3,0.2s0.6,0.3,0.7,0.5s0.3,0.4,0.3,0.7c0,0.2,0.1,0.5,0.1,1v1.4c0,1,0,1.6,0.1,1.9 s0.1,0.5,0.3,0.7h-1.1C113.7,20,113.7,19.8,113.6,19.5z M113.6,17.1c-0.4,0.2-1,0.3-1.7,0.4c-0.4,0.1-0.7,0.1-0.9,0.2 s-0.3,0.2-0.4,0.3s-0.1,0.3-0.1,0.5c0,0.3,0.1,0.5,0.3,0.7s0.5,0.3,0.9,0.3c0.4,0,0.8-0.1,1.1-0.3s0.5-0.4,0.7-0.7 c0.1-0.2,0.2-0.6,0.2-1.1V17.1z" />
|
||||||
|
<path class="st2" d="M116.4,20.2v-8.6h3.2c0.6,0,1,0,1.3,0.1c0.4,0.1,0.8,0.2,1.1,0.4s0.5,0.5,0.7,0.8s0.3,0.7,0.3,1.2 c0,0.7-0.2,1.3-0.7,1.9s-1.3,0.8-2.5,0.8h-2.2v3.5H116.4z M117.5,15.7h2.2c0.7,0,1.3-0.1,1.6-0.4s0.5-0.7,0.5-1.2 c0-0.4-0.1-0.7-0.3-0.9s-0.4-0.4-0.7-0.5c-0.2-0.1-0.5-0.1-1.1-0.1h-2.2V15.7z" />
|
||||||
|
<path class="st2" d="M123.9,17.1c0-1.2,0.3-2,1-2.6c0.5-0.5,1.2-0.7,2-0.7c0.9,0,1.6,0.3,2.1,0.8s0.8,1.3,0.8,2.3 c0,0.8-0.1,1.4-0.4,1.9s-0.6,0.8-1,1.1s-1,0.4-1.5,0.4c-0.9,0-1.6-0.3-2.1-0.8S123.9,18.2,123.9,17.1z M124.9,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.8,0.6,1.3,0.6c0.5,0,1-0.2,1.3-0.6s0.5-1,0.5-1.8c0-0.8-0.2-1.4-0.5-1.7s-0.8-0.6-1.3-0.6 c-0.5,0-1,0.2-1.3,0.6S124.9,16.3,124.9,17.1z" />
|
||||||
|
<path class="st2" d="M135,20.2v-0.8c-0.4,0.6-1,0.9-1.7,0.9c-0.5,0-1-0.1-1.4-0.4s-0.7-0.7-1-1.1s-0.3-1.1-0.3-1.7 c0-0.6,0.1-1.2,0.3-1.7s0.5-0.9,0.9-1.2s0.9-0.4,1.4-0.4c0.4,0,0.7,0.1,1,0.2s0.5,0.4,0.7,0.6v-3.1h1v8.6H135z M131.6,17.1 c0,0.8,0.2,1.4,0.5,1.8s0.7,0.6,1.2,0.6c0.5,0,0.9-0.2,1.2-0.6s0.5-1,0.5-1.7c0-0.9-0.2-1.5-0.5-1.9s-0.7-0.6-1.2-0.6 c-0.5,0-0.9,0.2-1.2,0.6S131.6,16.3,131.6,17.1z" />
|
||||||
|
<path class="st2" d="M137.2,18.4l1-0.2c0.1,0.4,0.2,0.7,0.5,1s0.6,0.3,1.1,0.3c0.5,0,0.8-0.1,1.1-0.3s0.4-0.4,0.4-0.7 c0-0.2-0.1-0.4-0.3-0.6c-0.1-0.1-0.5-0.2-1.1-0.4c-0.8-0.2-1.3-0.4-1.6-0.5s-0.5-0.3-0.7-0.6s-0.2-0.5-0.2-0.8 c0-0.3,0.1-0.5,0.2-0.8s0.3-0.4,0.5-0.6c0.2-0.1,0.4-0.2,0.7-0.3s0.6-0.1,0.9-0.1c0.5,0,0.9,0.1,1.3,0.2s0.6,0.3,0.8,0.6 s0.3,0.6,0.4,1l-1,0.1c0-0.3-0.2-0.6-0.4-0.8s-0.5-0.3-1-0.3c-0.5,0-0.8,0.1-1,0.2s-0.3,0.3-0.3,0.6c0,0.1,0,0.3,0.1,0.4 c0.1,0.1,0.2,0.2,0.4,0.3c0.1,0,0.4,0.1,0.9,0.3c0.7,0.2,1.3,0.4,1.6,0.5s0.5,0.3,0.7,0.6s0.3,0.5,0.3,0.9c0,0.4-0.1,0.7-0.3,1 s-0.5,0.6-0.9,0.7s-0.8,0.3-1.3,0.3c-0.8,0-1.4-0.2-1.8-0.5S137.3,19,137.2,18.4z" />
|
||||||
|
</g>
|
||||||
|
</svg></svg>
|
||||||
|
After Width: | Height: | Size: 6.3 KiB |
16
resources/hosting/zenith.svg
Normal file
16
resources/hosting/zenith.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 26 KiB |
|
|
@ -93,7 +93,8 @@
|
||||||
"addToPlaylist": "Gehitu zerrendara",
|
"addToPlaylist": "Gehitu zerrendara",
|
||||||
"download": "Deskargatu",
|
"download": "Deskargatu",
|
||||||
"info": "Erakutsi informazioa",
|
"info": "Erakutsi informazioa",
|
||||||
"share": "Partekatu"
|
"share": "Partekatu",
|
||||||
|
"refresh": "Freskatu metadatuak"
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"all": "Guztiak",
|
"all": "Guztiak",
|
||||||
|
|
@ -155,11 +156,13 @@
|
||||||
"newPassword": "Pasahitz berria",
|
"newPassword": "Pasahitz berria",
|
||||||
"token": "Tokena",
|
"token": "Tokena",
|
||||||
"lastAccessAt": "Azken sarbidea",
|
"lastAccessAt": "Azken sarbidea",
|
||||||
"libraries": "Liburutegiak"
|
"libraries": "Liburutegiak",
|
||||||
|
"scrobbleFilter": "Erabiltzailearen ohituren iragazkia"
|
||||||
},
|
},
|
||||||
"helperTexts": {
|
"helperTexts": {
|
||||||
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
|
"name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
|
||||||
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
|
"libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko",
|
||||||
|
"scrobbleFilter": "Erreprodukzio-zerrenda adimenduaren arauekin bat datozen abestiak ez dira Last.fm, ListenBrainz edo erabiltzailearen ohiturak aztertzen dituzten pluginetara bidaltzen. Erreprodukzio-zerrenda adimenduen JSON sintaxi eta portaera bera erabiltzen du. Adibidea: {\"all\":[{\"lt\":{\"rating\":4}}]}. Utzi hutsik erabiltzailearen ohitura guztiak aztertzeko. Ez dio eragiten lokalean erreproduzitutako zenbaketari."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"created": "Erabiltzailea sortu da",
|
"created": "Erabiltzailea sortu da",
|
||||||
|
|
@ -167,7 +170,8 @@
|
||||||
"deleted": "Erabiltzailea ezabatu da"
|
"deleted": "Erabiltzailea ezabatu da"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
|
"librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat",
|
||||||
|
"invalidScrobbleFilter": "Erreprodukzio-zerrenda adimendunaren baliozko arauak izan behar dute. Muga, konpentsazioa eta freskatze-atzerapena ez dira onartzen."
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
|
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
|
||||||
|
|
@ -196,6 +200,9 @@
|
||||||
"targetFormat": "Helburuko formatua",
|
"targetFormat": "Helburuko formatua",
|
||||||
"defaultBitRate": "Bit tasa, defektuz",
|
"defaultBitRate": "Bit tasa, defektuz",
|
||||||
"command": "Komandoa"
|
"command": "Komandoa"
|
||||||
|
},
|
||||||
|
"choices": {
|
||||||
|
"noDefaultBitRate": "Bat ere ez"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
|
|
@ -210,7 +217,8 @@
|
||||||
"songCount": "abesti",
|
"songCount": "abesti",
|
||||||
"comment": "Iruzkina",
|
"comment": "Iruzkina",
|
||||||
"sync": "Automatikoki inportatuak",
|
"sync": "Automatikoki inportatuak",
|
||||||
"path": "Inportatze-data:"
|
"path": "Inportatze-data:",
|
||||||
|
"starred": "Gogokoa"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"selectPlaylist": "Hautatu zerrenda:",
|
"selectPlaylist": "Hautatu zerrenda:",
|
||||||
|
|
@ -392,6 +400,7 @@
|
||||||
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
|
"invalidJson": "Konfigurazioa baliozko JSON-a izan behar da"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
|
"idHelp": "Pluginaren IDa, fitxategi-izenetik eratorria. Erabili konfigurazioaren aukeretan pluginari erreferentzia egitean, agenteak esaterako.",
|
||||||
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
|
"configHelp": "Konfiguratu plugina gako-balio bikoteak erabiliz. Utzi hutsik pluginak konfiguraziorik behar ez badu.",
|
||||||
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
|
"configValidationError": "Huts egin du konfigurazioaren balidazioak:",
|
||||||
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
|
"schemaRenderError": "Ezin izan da konfigurazioaren formularioa bihurtu. Litekeena da pluginaren eskema baliozkoa ez izatea.",
|
||||||
|
|
@ -565,6 +574,7 @@
|
||||||
"coverRemoved": "Diskoaren azala kendu da",
|
"coverRemoved": "Diskoaren azala kendu da",
|
||||||
"coverUploadError": "Errorea diskoaren azala igotzean",
|
"coverUploadError": "Errorea diskoaren azala igotzean",
|
||||||
"coverRemoveError": "Errorea diskoaren azala kentzean",
|
"coverRemoveError": "Errorea diskoaren azala kentzean",
|
||||||
|
"metadataRefreshStarted": "Metadata atzeko planoan freskatzen ari da",
|
||||||
"note": "OHARRA",
|
"note": "OHARRA",
|
||||||
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
"transcodingDisabled": "Segurtasun arrazoiak direla-eta, transkodeketaren ezarpenak web-interfazearen bidez aldatzea ezgaituta dago. Transkodeketa-aukerak aldatu (editatu edo gehitu) nahi badituzu, berrabiarazi zerbitzaria konfigurazio-aukeraren %{config}-arekin.",
|
||||||
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
"transcodingEnabled": "Navidrome %{config}-ekin martxan dago eta, beraz, web-interfazeko transkodeketa-ataletik sistema-komandoak exekuta daitezke. Segurtasun arrazoiak tarteko, ezgaitzea gomendatzen dugu, eta transkodeketa-aukerak konfiguratzen ari zarenean bakarrik gaitzea.",
|
||||||
|
|
@ -635,6 +645,7 @@
|
||||||
},
|
},
|
||||||
"albumList": "Albumak",
|
"albumList": "Albumak",
|
||||||
"playlists": "Zerrendak",
|
"playlists": "Zerrendak",
|
||||||
|
"onlyFavourites": "Erakutsi gogokoak bakarrik",
|
||||||
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
"sharedPlaylists": "Partekatutako erreprodukzio-zerrendak",
|
||||||
"about": "Honi buruz"
|
"about": "Honi buruz"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -328,11 +328,22 @@
|
||||||
"totalDuration": "Duração",
|
"totalDuration": "Duração",
|
||||||
"defaultNewUsers": "Padrão para Novos Usuários",
|
"defaultNewUsers": "Padrão para Novos Usuários",
|
||||||
"createdAt": "Data de Criação",
|
"createdAt": "Data de Criação",
|
||||||
"updatedAt": "Últ. Atualização"
|
"updatedAt": "Últ. Atualização",
|
||||||
|
"pidAlbum": "Agrupamento de álbuns",
|
||||||
|
"pidTrack": "Identificação das faixas"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"basic": "Informações Básicas",
|
"basic": "Informações Básicas",
|
||||||
"statistics": "Estatísticas"
|
"statistics": "Estatísticas",
|
||||||
|
"pid": "IDs Persistentes"
|
||||||
|
},
|
||||||
|
"pid": {
|
||||||
|
"global": "Usar configuração global (%{value})",
|
||||||
|
"folder": "Pasta (um álbum por pasta)",
|
||||||
|
"custom": "Personalizado",
|
||||||
|
"spec": "Especificação do PID",
|
||||||
|
"help": "Tags e atributos que identificam um item. Consulte a sintaxe na documentação:",
|
||||||
|
"docs": "IDs Persistentes"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"scan": "Scanear Biblioteca",
|
"scan": "Scanear Biblioteca",
|
||||||
|
|
@ -362,7 +373,9 @@
|
||||||
"messages": {
|
"messages": {
|
||||||
"deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.",
|
"deleteConfirm": "Tem certeza que deseja excluir esta biblioteca? Isso removerá todos os dados associados.",
|
||||||
"scanInProgress": "Scan em progresso...",
|
"scanInProgress": "Scan em progresso...",
|
||||||
"noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário"
|
"noLibrariesAssigned": "Nenhuma biblioteca atribuída a este usuário",
|
||||||
|
"pidChangeTitle": "Alterar os IDs persistentes?",
|
||||||
|
"pidChangeConfirm": "Ao salvar, os álbuns desta biblioteca serão reagrupados e as faixas serão identificadas novamente. Um scan completo da biblioteca começará imediatamente. As marcações como favoritas, as classificações e as contagens de reprodução das faixas serão mantidas. Os favoritos e as classificações dos álbuns serão transferidos para os novos álbuns quando um álbum antigo corresponder a um novo."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugin": {
|
"plugin": {
|
||||||
|
|
@ -647,6 +660,11 @@
|
||||||
"options": {
|
"options": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"language": "Língua",
|
"language": "Língua",
|
||||||
|
"dateFormat": "Formato de data",
|
||||||
|
"dateFormats": {
|
||||||
|
"language": "Igual à língua",
|
||||||
|
"browser": "Igual ao navegador"
|
||||||
|
},
|
||||||
"defaultView": "Tela inicial",
|
"defaultView": "Tela inicial",
|
||||||
"desktop_notifications": "Notificações",
|
"desktop_notifications": "Notificações",
|
||||||
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
|
"lastfmScrobbling": "Enviar scrobbles para Last.fm",
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,22 @@
|
||||||
"playCount": "Oynatılma",
|
"playCount": "Oynatılma",
|
||||||
"title": "Başlık",
|
"title": "Başlık",
|
||||||
"artist": "Sanatçı",
|
"artist": "Sanatçı",
|
||||||
|
"composer": "Besteci",
|
||||||
"album": "Albüm",
|
"album": "Albüm",
|
||||||
"path": "Dosya Yolu",
|
"path": "Dosya Yolu",
|
||||||
|
"libraryName": "Kütüphane",
|
||||||
"genre": "Tür",
|
"genre": "Tür",
|
||||||
"compilation": "Derleme",
|
"compilation": "Derleme",
|
||||||
"year": "Yıl",
|
"year": "Yıl",
|
||||||
"size": "Dosya Boyutu",
|
"size": "Dosya Boyutu",
|
||||||
"updatedAt": "Yüklendiği Tarih",
|
"updatedAt": "Yüklendiği Tarih",
|
||||||
"bitRate": "Bitrate",
|
"bitRate": "Bitrate",
|
||||||
|
"bitDepth": "Bit derinliği",
|
||||||
|
"sampleRate": "Örnekleme Oranı",
|
||||||
|
"albumGain": "Albüm Kazancı",
|
||||||
|
"trackGain": "Parça Kazancı",
|
||||||
|
"channels": "Kanal",
|
||||||
|
"disc": "Disk %{discNumber}",
|
||||||
"discSubtitle": "Disk Altyazısı",
|
"discSubtitle": "Disk Altyazısı",
|
||||||
"starred": "Favori",
|
"starred": "Favori",
|
||||||
"comment": "Yorum",
|
"comment": "Yorum",
|
||||||
|
|
@ -25,7 +33,6 @@
|
||||||
"quality": "Kalite",
|
"quality": "Kalite",
|
||||||
"bpm": "BPM",
|
"bpm": "BPM",
|
||||||
"playDate": "Son Oynatılma",
|
"playDate": "Son Oynatılma",
|
||||||
"channels": "Kanal",
|
|
||||||
"createdAt": "Eklenme tarihi",
|
"createdAt": "Eklenme tarihi",
|
||||||
"grouping": "Gruplama",
|
"grouping": "Gruplama",
|
||||||
"mood": "Mod",
|
"mood": "Mod",
|
||||||
|
|
@ -33,24 +40,17 @@
|
||||||
"tags": "Ek Etiketler",
|
"tags": "Ek Etiketler",
|
||||||
"mappedTags": "Eşlenen etiketler",
|
"mappedTags": "Eşlenen etiketler",
|
||||||
"rawTags": "Ham etiketler",
|
"rawTags": "Ham etiketler",
|
||||||
"bitDepth": "Bit derinliği",
|
"missing": "Eksik"
|
||||||
"sampleRate": "Örnekleme Oranı",
|
|
||||||
"missing": "Eksik",
|
|
||||||
"libraryName": "Kütüphane",
|
|
||||||
"composer": "Besteci",
|
|
||||||
"disc": "Disk %{discNumber}",
|
|
||||||
"albumGain": "Albüm Kazancı",
|
|
||||||
"trackGain": "Parça Kazancı"
|
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"addToQueue": "Oynatma Sırasına Ekle",
|
"addToQueue": "Oynatma Sırasına Ekle",
|
||||||
"playNow": "Şimdi Yürüt",
|
"playNow": "Şimdi Yürüt",
|
||||||
"addToPlaylist": "Çalma listesine ekle",
|
"addToPlaylist": "Çalma listesine ekle",
|
||||||
|
"showInPlaylist": "Çalma Listesinde Göster",
|
||||||
"shuffleAll": "Tümünü karıştır",
|
"shuffleAll": "Tümünü karıştır",
|
||||||
"download": "İndir",
|
"download": "İndir",
|
||||||
"playNext": "Dinlenenden Sonra Oynat",
|
"playNext": "Dinlenenden Sonra Oynat",
|
||||||
"info": "Bilgiler",
|
"info": "Bilgiler",
|
||||||
"showInPlaylist": "Çalma Listesinde Göster",
|
|
||||||
"instantMix": "Anında Karışım"
|
"instantMix": "Anında Karışım"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -62,38 +62,40 @@
|
||||||
"duration": "Süre",
|
"duration": "Süre",
|
||||||
"songCount": "Şarkı",
|
"songCount": "Şarkı",
|
||||||
"playCount": "Oynatılma",
|
"playCount": "Oynatılma",
|
||||||
|
"size": "Boyut",
|
||||||
"name": "Ad",
|
"name": "Ad",
|
||||||
|
"libraryName": "Kütüphane",
|
||||||
"genre": "Tür",
|
"genre": "Tür",
|
||||||
"compilation": "Derleme",
|
"compilation": "Derleme",
|
||||||
"year": "Yıl",
|
"year": "Yıl",
|
||||||
"updatedAt": "Güncellendi",
|
"date": "Kayıt Tarihi",
|
||||||
"comment": "Yorum",
|
|
||||||
"rating": "Derecelendirme",
|
|
||||||
"createdAt": "Eklenme tarihi",
|
|
||||||
"size": "Boyut",
|
|
||||||
"originalDate": "Orijinal",
|
"originalDate": "Orijinal",
|
||||||
"releaseDate": "Yayınlanma Tarihi",
|
"releaseDate": "Yayınlanma Tarihi",
|
||||||
"releases": "Yayınlanan |||| Yayınlananlar",
|
"releases": "Yayınlanan |||| Yayınlananlar",
|
||||||
"released": "Yayınlandı",
|
"released": "Yayınlandı",
|
||||||
|
"updatedAt": "Güncellendi",
|
||||||
|
"comment": "Yorum",
|
||||||
|
"rating": "Derecelendirme",
|
||||||
|
"createdAt": "Eklenme tarihi",
|
||||||
"recordLabel": "Etiket",
|
"recordLabel": "Etiket",
|
||||||
"catalogNum": "Katalog Numarası",
|
"catalogNum": "Katalog Numarası",
|
||||||
"releaseType": "Tür",
|
"releaseType": "Tür",
|
||||||
"grouping": "Gruplama",
|
"grouping": "Gruplama",
|
||||||
"media": "Medya",
|
"media": "Medya",
|
||||||
"mood": "Mod",
|
"mood": "Mod",
|
||||||
"date": "Kayıt Tarihi",
|
|
||||||
"missing": "Eksik",
|
"missing": "Eksik",
|
||||||
"libraryName": "Kütüphane"
|
"played": "Dinlendi"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"playAll": "Oynat",
|
"playAll": "Oynat",
|
||||||
"playNext": "Dinlenenden Sonra Oynat",
|
"playNext": "Dinlenenden Sonra Oynat",
|
||||||
"addToQueue": "Oynatma Kuyruğuna Ekle",
|
"addToQueue": "Oynatma Kuyruğuna Ekle",
|
||||||
|
"share": "Paylaş",
|
||||||
"shuffle": "Karıştır",
|
"shuffle": "Karıştır",
|
||||||
"addToPlaylist": "Çalma Listesine Ekle",
|
"addToPlaylist": "Çalma Listesine Ekle",
|
||||||
"download": "İndir",
|
"download": "İndir",
|
||||||
"info": "Bilgiler",
|
"info": "Bilgiler",
|
||||||
"share": "Paylaş"
|
"refresh": "Meta Verileri Yenile"
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"all": "Tümü",
|
"all": "Tümü",
|
||||||
|
|
@ -111,10 +113,10 @@
|
||||||
"name": "İsim",
|
"name": "İsim",
|
||||||
"albumCount": "Albüm Sayısı",
|
"albumCount": "Albüm Sayısı",
|
||||||
"songCount": "Şarkı Sayısı",
|
"songCount": "Şarkı Sayısı",
|
||||||
|
"size": "Boyut",
|
||||||
"playCount": "Oynatmalar",
|
"playCount": "Oynatmalar",
|
||||||
"rating": "Derecelendirme",
|
"rating": "Derecelendirme",
|
||||||
"genre": "Tür",
|
"genre": "Tür",
|
||||||
"size": "Boyut",
|
|
||||||
"role": "Rol",
|
"role": "Rol",
|
||||||
"missing": "Eksik"
|
"missing": "Eksik"
|
||||||
},
|
},
|
||||||
|
|
@ -135,9 +137,9 @@
|
||||||
"maincredit": "Albüm Sanatçısı veya Sanatçı |||| Albüm Sanatçısı veya Sanatçılar"
|
"maincredit": "Albüm Sanatçısı veya Sanatçı |||| Albüm Sanatçısı veya Sanatçılar"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"topSongs": "En İyi Şarkılar",
|
||||||
"shuffle": "Karıştır",
|
"shuffle": "Karıştır",
|
||||||
"radio": "Radyo",
|
"radio": "Radyo"
|
||||||
"topSongs": "En İyi Şarkılar"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
|
|
@ -146,6 +148,7 @@
|
||||||
"userName": "Kullanıcı adı",
|
"userName": "Kullanıcı adı",
|
||||||
"isAdmin": "Yönetici",
|
"isAdmin": "Yönetici",
|
||||||
"lastLoginAt": "Son Giriş Tarihi",
|
"lastLoginAt": "Son Giriş Tarihi",
|
||||||
|
"lastAccessAt": "Son Erişim Tarihi",
|
||||||
"updatedAt": "Güncelleme Tarihi",
|
"updatedAt": "Güncelleme Tarihi",
|
||||||
"name": "İsim",
|
"name": "İsim",
|
||||||
"password": "Şifre",
|
"password": "Şifre",
|
||||||
|
|
@ -154,30 +157,33 @@
|
||||||
"currentPassword": "Mevcut Şifre",
|
"currentPassword": "Mevcut Şifre",
|
||||||
"newPassword": "Yeni Şifre",
|
"newPassword": "Yeni Şifre",
|
||||||
"token": "Token",
|
"token": "Token",
|
||||||
"lastAccessAt": "Son Erişim Tarihi",
|
"libraries": "Kütüphaneler",
|
||||||
"libraries": "Kütüphaneler"
|
"scrobbleFilter": "Scrobble filtresi"
|
||||||
},
|
},
|
||||||
"helperTexts": {
|
"helperTexts": {
|
||||||
"name": "Adınızda yaptığımız değişikliğin geçerli olması için tekrar giriş yapmanız gerekmektedir",
|
"name": "Adınızda yaptığımız değişikliğin geçerli olması için tekrar giriş yapmanız gerekmektedir",
|
||||||
"libraries": "Bu kullanıcı için belirli kütüphaneleri seçin veya varsayılan kütüphaneleri kullanmak için boş bırakın"
|
"libraries": "Bu kullanıcı için belirli kütüphaneleri seçin veya varsayılan kütüphaneleri kullanmak için boş bırakın",
|
||||||
|
"scrobbleFilter": "Bu akıllı çalma listesi kurallarıyla eşleşen şarkılar Last.fm'e, ListenBrainz'e veya scrobbler eklentilerine gönderilmez. Akıllı çalma listeleriyle aynı JSON sözdizimini ve davranışını kullanır. Örnek: {\"all\":[{\"lt\":{\"rating\":4}}]}. Tüm şarkıları scrobble etmek için boş bırakın. Yerel çalma sayıları etkilenmez."
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"created": "Kullanıcı oluşturuldu",
|
"created": "Kullanıcı oluşturuldu",
|
||||||
"updated": "Kullanıcı güncellendi",
|
"updated": "Kullanıcı güncellendi",
|
||||||
"deleted": "Kullanıcı silindi"
|
"deleted": "Kullanıcı silindi"
|
||||||
},
|
},
|
||||||
|
"validation": {
|
||||||
|
"librariesRequired": "Yönetici olmayan kullanıcılar için en az bir kütüphane seçilmelidir",
|
||||||
|
"invalidScrobbleFilter": "Geçerli akıllı çalma listesi kuralları olmalıdır. Limit, offset ve yenileme gecikmesi desteklenmez."
|
||||||
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"listenBrainzToken": "ListenBrainz kullanıcı Token'ınızı girin.",
|
"listenBrainzToken": "ListenBrainz kullanıcı Token'ınızı girin.",
|
||||||
"clickHereForToken": "Token almak için buraya tıklayın",
|
"clickHereForToken": "Token almak için buraya tıklayın",
|
||||||
"selectAllLibraries": "Tüm kütüphaneleri seç",
|
"selectAllLibraries": "Tüm kütüphaneleri seç",
|
||||||
"adminAutoLibraries": "Yönetici yetkili kullanıcılar tüm kütüphanelere otomatik olarak erişebilir"
|
"adminAutoLibraries": "Yönetici yetkili kullanıcılar tüm kütüphanelere otomatik olarak erişebilir"
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"librariesRequired": "Yönetici olmayan kullanıcılar için en az bir kütüphane seçilmelidir"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"name": "Cihaz |||| Cihazlar",
|
"name": "Cihaz |||| Cihazlar",
|
||||||
|
"menuName": "Cihazlar ve API anahtarları",
|
||||||
"fields": {
|
"fields": {
|
||||||
"name": "İsim",
|
"name": "İsim",
|
||||||
"transcodingId": "Kod Dönüştürme",
|
"transcodingId": "Kod Dönüştürme",
|
||||||
|
|
@ -186,7 +192,29 @@
|
||||||
"userName": "Kullanıcı adı",
|
"userName": "Kullanıcı adı",
|
||||||
"lastSeen": "Son Görüldüğü Yer",
|
"lastSeen": "Son Görüldüğü Yer",
|
||||||
"reportRealPath": "Gerçek Yolu Bildir",
|
"reportRealPath": "Gerçek Yolu Bildir",
|
||||||
"scrobbleEnabled": "Skroplamaları harici servislere gönder"
|
"scrobbleEnabled": "Skroplamaları harici servislere gönder",
|
||||||
|
"hasApiKey": "API Anahtarı"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"generateApiKey": "API anahtarı oluştur",
|
||||||
|
"regenerateApiKey": "Yeniden oluştur",
|
||||||
|
"revokeApiKey": "İptal et",
|
||||||
|
"copyApiKey": "Kopyala"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"apiKeyActive": "Bu cihazın bir API anahtarı var. Bunu uygulamanızda API anahtarı olarak veya uygulamanız belirteç tabanlı kimlik doğrulama kullanmıyorsa parola olarak kullanın.",
|
||||||
|
"apiKeyNone": "API anahtarı yok. Bir uygulamayı bu cihaza bağlamak için bir anahtar oluşturun.",
|
||||||
|
"apiKeyNoneOther": "API anahtarı yok.",
|
||||||
|
"apiKeyPending": "Bu anahtarı şimdi kopyalayın. Kaydet'e tıkladığınızda kaydedilir ve bir daha gösterilmez.",
|
||||||
|
"apiKeyRevokePending": "Kaydettiğinizde API anahtarı kaldırılacak.",
|
||||||
|
"deleteWithKeyTitle": "Cihazı sil",
|
||||||
|
"deleteWithKeyContent": "Bu cihazın bir API anahtarı var. Bu anahtarı kullanan uygulamalar çalışmayı durduracak."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"apiKeyCopied": "API anahtarı panoya kopyalandı"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"apiKeyFormat": "Geçersiz API anahtarı biçimi"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"transcoding": {
|
"transcoding": {
|
||||||
|
|
@ -196,6 +224,9 @@
|
||||||
"targetFormat": "Dönüştürme Formatı",
|
"targetFormat": "Dönüştürme Formatı",
|
||||||
"defaultBitRate": "Varsayılan BitRate",
|
"defaultBitRate": "Varsayılan BitRate",
|
||||||
"command": "Komut"
|
"command": "Komut"
|
||||||
|
},
|
||||||
|
"choices": {
|
||||||
|
"noDefaultBitRate": "Yok"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"playlist": {
|
"playlist": {
|
||||||
|
|
@ -210,15 +241,16 @@
|
||||||
"songCount": "Şarkı",
|
"songCount": "Şarkı",
|
||||||
"comment": "Yorum",
|
"comment": "Yorum",
|
||||||
"sync": "Otomatik Aktar\n",
|
"sync": "Otomatik Aktar\n",
|
||||||
"path": "İçe Aktar"
|
"path": "İçe Aktar",
|
||||||
|
"starred": "Favori"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"selectPlaylist": "Bir Çalma Listesi Seç:",
|
"selectPlaylist": "Bir Çalma Listesi Seç:",
|
||||||
"addNewPlaylist": "Oluştur \"%{name}\"",
|
"addNewPlaylist": "Oluştur \"%{name}\"",
|
||||||
"export": "Aktar",
|
"export": "Aktar",
|
||||||
|
"saveQueue": "Kuyruktakileri Çalma Listesine Kaydet",
|
||||||
"makePublic": "Herkese Açık Yap",
|
"makePublic": "Herkese Açık Yap",
|
||||||
"makePrivate": "Özel Yap",
|
"makePrivate": "Özel Yap",
|
||||||
"saveQueue": "Kuyruktakileri Çalma Listesine Kaydet",
|
|
||||||
"searchOrCreate": "Çalma listelerini arayın veya yenisini oluşturmak için yazın...",
|
"searchOrCreate": "Çalma listelerini arayın veya yenisini oluşturmak için yazın...",
|
||||||
"pressEnterToCreate": "Yeni çalma listesi oluşturmak için Enter'a basın",
|
"pressEnterToCreate": "Yeni çalma listesi oluşturmak için Enter'a basın",
|
||||||
"removeFromSelection": "Seçimden kaldır"
|
"removeFromSelection": "Seçimden kaldır"
|
||||||
|
|
@ -249,6 +281,7 @@
|
||||||
"username": "Paylaşan",
|
"username": "Paylaşan",
|
||||||
"url": "URL",
|
"url": "URL",
|
||||||
"description": "Tanım",
|
"description": "Tanım",
|
||||||
|
"downloadable": "İndirmelere İzin Ver",
|
||||||
"contents": "İçindekiler",
|
"contents": "İçindekiler",
|
||||||
"expiresAt": "Sona Erme Tarihi",
|
"expiresAt": "Sona Erme Tarihi",
|
||||||
"lastVisitedAt": "Son Ziyaret Tarihi",
|
"lastVisitedAt": "Son Ziyaret Tarihi",
|
||||||
|
|
@ -256,17 +289,19 @@
|
||||||
"format": "Format",
|
"format": "Format",
|
||||||
"maxBitRate": "Maks. Bit Rate",
|
"maxBitRate": "Maks. Bit Rate",
|
||||||
"updatedAt": "Güncelleme Tarihi",
|
"updatedAt": "Güncelleme Tarihi",
|
||||||
"createdAt": "Oluşturma Tarihi",
|
"createdAt": "Oluşturma Tarihi"
|
||||||
"downloadable": "İndirmelere İzin Ver"
|
},
|
||||||
}
|
"notifications": {},
|
||||||
|
"actions": {}
|
||||||
},
|
},
|
||||||
"missing": {
|
"missing": {
|
||||||
"name": "Eksik Dosya |||| Eksik Dosyalar",
|
"name": "Eksik Dosya |||| Eksik Dosyalar",
|
||||||
|
"empty": "Eksik Dosya Yok",
|
||||||
"fields": {
|
"fields": {
|
||||||
"path": "Yol",
|
"path": "Yol",
|
||||||
"size": "Boyut",
|
"size": "Boyut",
|
||||||
"updatedAt": "Kaybolma",
|
"libraryName": "Kütüphane",
|
||||||
"libraryName": "Kütüphane"
|
"updatedAt": "Kaybolma"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"remove": "Kaldır",
|
"remove": "Kaldır",
|
||||||
|
|
@ -274,8 +309,7 @@
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"removed": "Eksik dosya(lar) kaldırıldı"
|
"removed": "Eksik dosya(lar) kaldırıldı"
|
||||||
},
|
}
|
||||||
"empty": "Eksik Dosya Yok"
|
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
"name": "Kütüphane |||| Kütüphaneler",
|
"name": "Kütüphane |||| Kütüphaneler",
|
||||||
|
|
@ -297,28 +331,39 @@
|
||||||
"totalDuration": "Süre",
|
"totalDuration": "Süre",
|
||||||
"defaultNewUsers": "Yeni Kullanıcılar için Varsayılan",
|
"defaultNewUsers": "Yeni Kullanıcılar için Varsayılan",
|
||||||
"createdAt": "Oluşturuldu",
|
"createdAt": "Oluşturuldu",
|
||||||
"updatedAt": "Güncellendi"
|
"updatedAt": "Güncellendi",
|
||||||
|
"pidAlbum": "Albüm gruplama",
|
||||||
|
"pidTrack": "Parça kimliği"
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
"basic": "Temel Bilgiler",
|
"basic": "Temel Bilgiler",
|
||||||
"statistics": "İstatistikler"
|
"statistics": "İstatistikler",
|
||||||
|
"pid": "Kalıcı Kimlikler"
|
||||||
|
},
|
||||||
|
"pid": {
|
||||||
|
"global": "Genel ayarı kullan (%{value})",
|
||||||
|
"folder": "Klasör (klasör başına bir albüm)",
|
||||||
|
"custom": "Özel",
|
||||||
|
"spec": "PID belirtimi",
|
||||||
|
"help": "Bir öğeyi tanımlayan etiketler ve öznitelikler. Sözdizimi için belgelere bakın:",
|
||||||
|
"docs": "Kalıcı kimlikler"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"scan": "Kütüphaneyi Tara",
|
"scan": "Kütüphaneyi Tara",
|
||||||
"manageUsers": "Kullanıcı Erişimini Yönet",
|
|
||||||
"viewDetails": "Ayrıntıları Görüntüle",
|
|
||||||
"quickScan": "Hızlı Tarama",
|
"quickScan": "Hızlı Tarama",
|
||||||
"fullScan": "Tam Tarama"
|
"fullScan": "Tam Tarama",
|
||||||
|
"manageUsers": "Kullanıcı Erişimini Yönet",
|
||||||
|
"viewDetails": "Ayrıntıları Görüntüle"
|
||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"created": "Kütüphane başarıyla oluşturuldu",
|
"created": "Kütüphane başarıyla oluşturuldu",
|
||||||
"updated": "Kütüphane başarıyla güncellendi",
|
"updated": "Kütüphane başarıyla güncellendi",
|
||||||
"deleted": "Kütüphane başarıyla silindi",
|
"deleted": "Kütüphane başarıyla silindi",
|
||||||
"scanStarted": "Kütüphane taraması başladı",
|
"scanStarted": "Kütüphane taraması başladı",
|
||||||
"scanCompleted": "Kütüphane taraması tamamlandı",
|
|
||||||
"quickScanStarted": "Hızlı tarama başlatıldı",
|
"quickScanStarted": "Hızlı tarama başlatıldı",
|
||||||
"fullScanStarted": "Tam tarama başlatıldı",
|
"fullScanStarted": "Tam tarama başlatıldı",
|
||||||
"scanError": "Tarama başlatılırken hata oluştu. Günlükleri kontrol edin."
|
"scanError": "Tarama başlatılırken hata oluştu. Günlükleri kontrol edin.",
|
||||||
|
"scanCompleted": "Kütüphane taraması tamamlandı"
|
||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "Kütüphane adı gereklidir",
|
"nameRequired": "Kütüphane adı gereklidir",
|
||||||
|
|
@ -331,7 +376,9 @@
|
||||||
"messages": {
|
"messages": {
|
||||||
"deleteConfirm": "Bu kütüphaneyi silmek istediğinizden emin misiniz? Bu işlem, ilgili tüm verileri ve kullanıcı erişimini kaldıracaktır.",
|
"deleteConfirm": "Bu kütüphaneyi silmek istediğinizden emin misiniz? Bu işlem, ilgili tüm verileri ve kullanıcı erişimini kaldıracaktır.",
|
||||||
"scanInProgress": "Tarama devam ediyor...",
|
"scanInProgress": "Tarama devam ediyor...",
|
||||||
"noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı"
|
"noLibrariesAssigned": "Bu kullanıcıya hiçbir kütüphane atanmadı",
|
||||||
|
"pidChangeTitle": "Kalıcı kimlikler değiştirilsin mi?",
|
||||||
|
"pidChangeConfirm": "Bu işlem, bu kitaplıktaki albüm ve parçaları yeniden gruplandırır. Kitaplığın tamamı şimdi yeniden taranır. Parça yıldızları, puanları ve çalma sayıları korunur. Eski bir albümün yeni bir albümle eşleştiği durumlarda, albüm yıldızları ve puanları yeni albümlere aktarılır."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugin": {
|
"plugin": {
|
||||||
|
|
@ -390,7 +437,10 @@
|
||||||
"invalidJson": "Yapılandırma geçerli bir JSON olmalı"
|
"invalidJson": "Yapılandırma geçerli bir JSON olmalı"
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
|
"idHelp": "Eklenti kimliği, dosya adından türetilir. Bu eklentiye Agents gibi yapılandırma seçeneklerinde referans verirken bu kimliği kullanın.",
|
||||||
"configHelp": "Eklentiyi anahtar-değer çiftleriyle yapılandırın. Eklenti yapılandırma gerektirmiyorsa boş bırakın.",
|
"configHelp": "Eklentiyi anahtar-değer çiftleriyle yapılandırın. Eklenti yapılandırma gerektirmiyorsa boş bırakın.",
|
||||||
|
"configValidationError": "Yapılandırma doğrulanamadı:",
|
||||||
|
"schemaRenderError": "Yapılandırma formu oluşturulamadı. Eklentinin şeması geçersiz olabilir.",
|
||||||
"clickPermissions": "Ayrıntıları görmek için bir izne tıklayın",
|
"clickPermissions": "Ayrıntıları görmek için bir izne tıklayın",
|
||||||
"noConfig": "Yapılandırma ayarlanmamış",
|
"noConfig": "Yapılandırma ayarlanmamış",
|
||||||
"allUsersHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kullanıcılara erişebilir.",
|
"allUsersHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kullanıcılara erişebilir.",
|
||||||
|
|
@ -400,10 +450,8 @@
|
||||||
"allLibrariesHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kütüphanelere erişebilir.",
|
"allLibrariesHelp": "Etkinleştirildiğinde eklenti, ileride oluşturulanlar dahil tüm kütüphanelere erişebilir.",
|
||||||
"noLibraries": "Kütüphane seçilmedi",
|
"noLibraries": "Kütüphane seçilmedi",
|
||||||
"librariesRequired": "Bu eklenti kütüphane bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kütüphaneleri seçin veya 'Tüm Kütüphanelere İzin Ver' \nseçeneğini etkinleştirin.",
|
"librariesRequired": "Bu eklenti kütüphane bilgilerine erişim gerektiriyor. Eklentinin erişebileceği kütüphaneleri seçin veya 'Tüm Kütüphanelere İzin Ver' \nseçeneğini etkinleştirin.",
|
||||||
"requiredHosts": "Gerekli Sunucular",
|
"allowWriteAccessHelp": "Etkinleştirildiğinde eklenti, kütüphane dizinlerindeki dosyaları değiştirebilir. Eklentiler varsayılan olarak salt okunur erişime sahiptir.",
|
||||||
"configValidationError": "Yapılandırma doğrulanamadı:",
|
"requiredHosts": "Gerekli Sunucular"
|
||||||
"schemaRenderError": "Yapılandırma formu oluşturulamadı. Eklentinin şeması geçersiz olabilir.",
|
|
||||||
"allowWriteAccessHelp": "Etkinleştirildiğinde eklenti, kütüphane dizinlerindeki dosyaları değiştirebilir. Eklentiler varsayılan olarak salt okunur erişime sahiptir."
|
|
||||||
},
|
},
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"configKey": "anahtar",
|
"configKey": "anahtar",
|
||||||
|
|
@ -433,7 +481,7 @@
|
||||||
"minLength": "En az %{min} karakter olmalıdır",
|
"minLength": "En az %{min} karakter olmalıdır",
|
||||||
"maxLength": "En fazla %{max} karakter olmalıdır",
|
"maxLength": "En fazla %{max} karakter olmalıdır",
|
||||||
"minValue": "En az %{min} olmalı",
|
"minValue": "En az %{min} olmalı",
|
||||||
"maxValue": "En az %{min} olmalıdır",
|
"maxValue": "En fazla %{max} olmalıdır",
|
||||||
"number": "Sayısal bir değer olmalı",
|
"number": "Sayısal bir değer olmalı",
|
||||||
"email": "E-posta geçerli değil",
|
"email": "E-posta geçerli değil",
|
||||||
"oneOf": "Şunlardan biri olmalı: %{options}",
|
"oneOf": "Şunlardan biri olmalı: %{options}",
|
||||||
|
|
@ -446,6 +494,7 @@
|
||||||
"add": "Ekle",
|
"add": "Ekle",
|
||||||
"back": "Geri Dön",
|
"back": "Geri Dön",
|
||||||
"bulk_actions": "1 öğe seçildi |||| %{smart_count} öğe seçildi",
|
"bulk_actions": "1 öğe seçildi |||| %{smart_count} öğe seçildi",
|
||||||
|
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
||||||
"cancel": "Vazgeç",
|
"cancel": "Vazgeç",
|
||||||
"clear_input_value": "Değeri Temizle",
|
"clear_input_value": "Değeri Temizle",
|
||||||
"clone": "Klonla",
|
"clone": "Klonla",
|
||||||
|
|
@ -469,7 +518,6 @@
|
||||||
"close_menu": "Menüyü kapat",
|
"close_menu": "Menüyü kapat",
|
||||||
"unselect": "Seçimi kaldır",
|
"unselect": "Seçimi kaldır",
|
||||||
"skip": "Atla",
|
"skip": "Atla",
|
||||||
"bulk_actions_mobile": "1 |||| %{smart_count}",
|
|
||||||
"share": "Paylaş",
|
"share": "Paylaş",
|
||||||
"download": "İndir"
|
"download": "İndir"
|
||||||
},
|
},
|
||||||
|
|
@ -557,53 +605,71 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"note": "NOT",
|
|
||||||
"transcodingDisabled": "Güvenlik nedenleriyle, kod dönüştürme yapılandırmasını web arayüzü üzerinden değiştirmek devre dışıdır. Kod dönüştürme seçeneklerini değiştirmek (düzenlemek veya eklemek) isterseniz, sunucuyu %{config} yapılandırma seçeneğiyle yeniden başlatın.",
|
|
||||||
"transcodingEnabled": "Navidrome yapılandırmanızda \" %{config} \" etkin ve bu da web arayüzünü kullanarak kod dönüştürme ayarlarından sistem komutlarını çalıştırmayı mümkün kılıyor. Güvenlik nedenleriyle bunu devre dışı bırakmanızı ve yalnızca Kod dönüştürme seçeneklerini yapılandırırken etkinleştirmenizi öneririz.",
|
|
||||||
"songsAddedToPlaylist": "Çalma listesine 1 şarkı eklendi |||| Çalma listesine %{smart_count} şarkı eklendi",
|
|
||||||
"noPlaylistsAvailable": "Mevcut değil",
|
|
||||||
"delete_user_title": "'%{name}' kullanıcısını sil",
|
|
||||||
"delete_user_content": "Bu kullanıcıyı ve tüm verilerini (çalma listeleri ve tercihleri dahil) silmek istediğinden emin misin?",
|
|
||||||
"notifications_blocked": "Tarayıcınızın ayarlarında bu site için Bildirimleri engellediniz",
|
|
||||||
"notifications_not_available": "Bu tarayıcı masaüstü bildirimlerini desteklemiyor veya Navidrome'a \"https://\" üzerinden erişmiyorsunuz",
|
|
||||||
"lastfmLinkSuccess": "Last.fm bağlantısı başarılı ve skroplama etkinleştirildi",
|
|
||||||
"lastfmLinkFailure": "Last.fm'e bağlanılamadı",
|
|
||||||
"lastfmUnlinkSuccess": "Last.fm bağlantısı kaldırıldı ve Skroplama devre dışı bırakıldı",
|
|
||||||
"lastfmUnlinkFailure": "Last.fm bağlantısı kaldırılamadı",
|
|
||||||
"openIn": {
|
|
||||||
"lastfm": "Last.fm'de aç",
|
|
||||||
"musicbrainz": "MusicBrainz'de aç"
|
|
||||||
},
|
|
||||||
"lastfmLink": "Devamını Oku...",
|
|
||||||
"listenBrainzLinkSuccess": "ListenBrainz bağlantısı başarılı ve skroplama %{user} tarafından etkinleştirildi",
|
|
||||||
"listenBrainzLinkFailure": "ListenBrainz'a bağlanılamadı: %{error}",
|
|
||||||
"listenBrainzUnlinkSuccess": "ListenBrainz bağlantısı kaldırıldı ve skroplama devre dışı bırakıldı",
|
|
||||||
"listenBrainzUnlinkFailure": "ListenBrainz bağlantısı kaldırılamadı",
|
|
||||||
"downloadOriginalFormat": "Orijinal formatta indir",
|
|
||||||
"shareOriginalFormat": "Orijinal Formatta Paylaş",
|
|
||||||
"shareDialogTitle": "%{resource}: '%{name}' öğesini paylaş",
|
|
||||||
"shareBatchDialogTitle": "Paylaş 1 %{resource} |||| Paylaş %{smart_count} %{resource}",
|
|
||||||
"shareSuccess": "URL panoya kopyalandı: %{url}",
|
|
||||||
"shareFailure": "%{url} panoya kopyalanırken hata oluştu",
|
|
||||||
"downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin",
|
|
||||||
"shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter",
|
|
||||||
"remove_missing_title": "Eksik dosyaları kaldır",
|
|
||||||
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.",
|
|
||||||
"remove_all_missing_title": "Tüm eksik dosyaları kaldırın",
|
|
||||||
"remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayısı ve derecelendirmelerde dahil olmak üzere bunlara ilişkili tüm değerleri kalıcı olarak kaldıracaktır.",
|
|
||||||
"noSimilarSongsFound": "Benzer şarkı bulunamadı",
|
|
||||||
"noTopSongsFound": "En iyi şarkı listesi boş",
|
|
||||||
"startingInstantMix": "Anında Karışım yükleniyor...",
|
|
||||||
"uploadCover": "Kapak Görseli Yükle",
|
"uploadCover": "Kapak Görseli Yükle",
|
||||||
"removeCover": "Kapak Görselini Kaldır",
|
"removeCover": "Kapak Görselini Kaldır",
|
||||||
"coverUploaded": "Kapak görseli güncellendi",
|
"coverUploaded": "Kapak görseli güncellendi",
|
||||||
"coverRemoved": "Kapak görseli kaldırıldı",
|
"coverRemoved": "Kapak görseli kaldırıldı",
|
||||||
"coverUploadError": "Kapak görseli yüklenirken hata oluştu",
|
"coverUploadError": "Kapak görseli yüklenirken hata oluştu",
|
||||||
"coverRemoveError": "Kapak görseli kaldırılırken hata oluştu"
|
"coverRemoveError": "Kapak görseli kaldırılırken hata oluştu",
|
||||||
|
"metadataRefreshStarted": "Meta veriler arka planda yenileniyor",
|
||||||
|
"note": "NOT",
|
||||||
|
"transcodingDisabled": "Güvenlik nedenleriyle, kod dönüştürme yapılandırmasını web arayüzü üzerinden değiştirmek devre dışıdır. Kod dönüştürme seçeneklerini değiştirmek (düzenlemek veya eklemek) isterseniz, sunucuyu %{config} yapılandırma seçeneğiyle yeniden başlatın.",
|
||||||
|
"transcodingEnabled": "Navidrome yapılandırmanızda \" %{config} \" etkin ve bu da web arayüzünü kullanarak kod dönüştürme ayarlarından sistem komutlarını çalıştırmayı mümkün kılıyor. Güvenlik nedenleriyle bunu devre dışı bırakmanızı ve yalnızca Kod dönüştürme seçeneklerini yapılandırırken etkinleştirmenizi öneririz.",
|
||||||
|
"songsAddedToPlaylist": "Çalma listesine 1 şarkı eklendi |||| Çalma listesine %{smart_count} şarkı eklendi",
|
||||||
|
"noSimilarSongsFound": "Benzer şarkı bulunamadı",
|
||||||
|
"startingInstantMix": "Anında Karışım yükleniyor...",
|
||||||
|
"noTopSongsFound": "En iyi şarkı listesi boş",
|
||||||
|
"noPlaylistsAvailable": "Mevcut değil",
|
||||||
|
"delete_user_title": "'%{name}' kullanıcısını sil",
|
||||||
|
"delete_user_content": "Bu kullanıcıyı ve tüm verilerini (çalma listeleri ve tercihleri dahil) silmek istediğinden emin misin?",
|
||||||
|
"remove_missing_title": "Eksik dosyaları kaldır",
|
||||||
|
"remove_missing_content": "Seçili eksik dosyaları veritabanından kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeleri dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.",
|
||||||
|
"remove_all_missing_title": "Tüm eksik dosyaları kaldırın",
|
||||||
|
"remove_all_missing_content": "Veritabanından tüm eksik dosyaları kaldırmak istediğinizden emin misiniz? Bu, oynatma sayıları ve derecelendirmeler de dahil olmak üzere bunlara ilişkin tüm referansları kalıcı olarak kaldıracaktır.",
|
||||||
|
"notifications_blocked": "Tarayıcınızın ayarlarında bu site için Bildirimleri engellediniz",
|
||||||
|
"notifications_not_available": "Bu tarayıcı masaüstü bildirimlerini desteklemiyor veya Navidrome'a \"https://\" üzerinden erişmiyorsunuz",
|
||||||
|
"quickConnectApproved": "%{device} üzerindeki %{app} uygulamasında oturum açıldı",
|
||||||
|
"quickConnectInvalidCode": "Kod geçersiz veya süresi dolmuş",
|
||||||
|
"quickConnectError": "Kod onaylanamadı",
|
||||||
|
"lastfmLinkSuccess": "Last.fm bağlantısı başarılı ve skroplama etkinleştirildi",
|
||||||
|
"lastfmLinkFailure": "Last.fm'e bağlanılamadı",
|
||||||
|
"lastfmUnlinkSuccess": "Last.fm bağlantısı kaldırıldı ve Skroplama devre dışı bırakıldı",
|
||||||
|
"lastfmUnlinkFailure": "Last.fm bağlantısı kaldırılamadı",
|
||||||
|
"listenBrainzLinkSuccess": "ListenBrainz bağlantısı başarılı ve %{user} kullanıcısı olarak skroplama etkinleştirildi",
|
||||||
|
"listenBrainzLinkFailure": "ListenBrainz'a bağlanılamadı: %{error}",
|
||||||
|
"listenBrainzUnlinkSuccess": "ListenBrainz bağlantısı kaldırıldı ve skroplama devre dışı bırakıldı",
|
||||||
|
"listenBrainzUnlinkFailure": "ListenBrainz bağlantısı kaldırılamadı",
|
||||||
|
"openIn": {
|
||||||
|
"lastfm": "Last.fm'de aç",
|
||||||
|
"musicbrainz": "MusicBrainz'de aç"
|
||||||
|
},
|
||||||
|
"lastfmLink": "Devamını Oku...",
|
||||||
|
"shareOriginalFormat": "Orijinal Formatta Paylaş",
|
||||||
|
"shareDialogTitle": "%{resource}: '%{name}' öğesini paylaş",
|
||||||
|
"shareBatchDialogTitle": "Paylaş 1 %{resource} |||| Paylaş %{smart_count} %{resource}",
|
||||||
|
"shareCopyToClipboard": "Panoya kopyala: Ctrl+C, Enter",
|
||||||
|
"shareSuccess": "URL panoya kopyalandı: %{url}",
|
||||||
|
"shareFailure": "%{url} panoya kopyalanırken hata oluştu",
|
||||||
|
"downloadDialogTitle": "%{resource}: '%{name}' (%{size}) dosyasını indirin",
|
||||||
|
"downloadOriginalFormat": "Orijinal formatta indir"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"library": "Kütüphane",
|
"library": "Kütüphane",
|
||||||
|
"librarySelector": {
|
||||||
|
"allLibraries": "Tüm Kütüphaneler (%{count})",
|
||||||
|
"multipleLibraries": "%{total} kütüphaneden %{selected} tanesi seçildi",
|
||||||
|
"selectLibraries": "Seçili Kütüphaneler",
|
||||||
|
"none": "Hiçbiri"
|
||||||
|
},
|
||||||
"settings": "Ayarlar",
|
"settings": "Ayarlar",
|
||||||
|
"quickConnect": {
|
||||||
|
"name": "Hızlı Bağlantı",
|
||||||
|
"code": "Kod",
|
||||||
|
"help": "Bir Jellyfin uygulamasının hesabınızda oturum açması için uygulamada gösterilen kodu girin",
|
||||||
|
"confirm": "%{device} cihazındaki %{app} %{version} uygulamasının hesabınızda oturum açmasına izin veriyor musunuz?",
|
||||||
|
"continue": "Devam et",
|
||||||
|
"approve": "Onayla"
|
||||||
|
},
|
||||||
"version": "Versiyon",
|
"version": "Versiyon",
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"personal": {
|
"personal": {
|
||||||
|
|
@ -611,8 +677,14 @@
|
||||||
"options": {
|
"options": {
|
||||||
"theme": "Tema",
|
"theme": "Tema",
|
||||||
"language": "Dil",
|
"language": "Dil",
|
||||||
|
"dateFormat": "Tarih Formatı",
|
||||||
|
"dateFormats": {
|
||||||
|
"language": "Dille eşleştir",
|
||||||
|
"browser": "Tarayıcıyla eşleştir"
|
||||||
|
},
|
||||||
"defaultView": "Varsayılan Görünüm",
|
"defaultView": "Varsayılan Görünüm",
|
||||||
"desktop_notifications": "Masaüstü Bildirimleri",
|
"desktop_notifications": "Masaüstü Bildirimleri",
|
||||||
|
"lastfmNotConfigured": "Last.fm API Anahtarı yapılandırılmadı\n",
|
||||||
"lastfmScrobbling": "Last.fm'e Skropla",
|
"lastfmScrobbling": "Last.fm'e Skropla",
|
||||||
"listenBrainzScrobbling": "ListenBrainz'e Skropla",
|
"listenBrainzScrobbling": "ListenBrainz'e Skropla",
|
||||||
"replaygain": "Tekrar Kazanma Modu",
|
"replaygain": "Tekrar Kazanma Modu",
|
||||||
|
|
@ -621,20 +693,14 @@
|
||||||
"none": "Devre dışı",
|
"none": "Devre dışı",
|
||||||
"album": "Albüm Kazancını Kullan",
|
"album": "Albüm Kazancını Kullan",
|
||||||
"track": "Parça Kazancını Kullan"
|
"track": "Parça Kazancını Kullan"
|
||||||
},
|
}
|
||||||
"lastfmNotConfigured": "Last.fm API Anahtarı yapılandırılmadı\n"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"albumList": "Albümler",
|
"albumList": "Albümler",
|
||||||
"about": "Hakkında",
|
|
||||||
"playlists": "Çalma Listeleri",
|
"playlists": "Çalma Listeleri",
|
||||||
|
"onlyFavourites": "Yalnızca favorileri göster",
|
||||||
"sharedPlaylists": "Paylaşılan Çalma Listeleri",
|
"sharedPlaylists": "Paylaşılan Çalma Listeleri",
|
||||||
"librarySelector": {
|
"about": "Hakkında"
|
||||||
"allLibraries": "Tüm Kitaplıklar (%{count})",
|
|
||||||
"multipleLibraries": "%{total} kütüphaneden %{selected} tanesi seçildi",
|
|
||||||
"selectLibraries": "Seçili Kütüphaneler",
|
|
||||||
"none": "Hiçbiri"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
"playListsText": "Oynatma Sırası",
|
"playListsText": "Oynatma Sırası",
|
||||||
|
|
@ -682,11 +748,11 @@
|
||||||
"currentValue": "Güncel Değer",
|
"currentValue": "Güncel Değer",
|
||||||
"configurationFile": "Yapılandırma Dosyası",
|
"configurationFile": "Yapılandırma Dosyası",
|
||||||
"exportToml": "Yapılandırmayı Dışa Aktar (TOML)",
|
"exportToml": "Yapılandırmayı Dışa Aktar (TOML)",
|
||||||
|
"downloadToml": "Yapılandırmayı İndir (TOML)",
|
||||||
"exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı",
|
"exportSuccess": "Yapılandırma TOML formatında dışa aktarıldı",
|
||||||
"exportFailed": "Yapılandırma kopyalanamadı",
|
"exportFailed": "Yapılandırma kopyalanamadı",
|
||||||
"devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)",
|
"devFlagsHeader": "Geliştirme Bayrakları (değişime/kaldırılmaya tabidir)",
|
||||||
"devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir",
|
"devFlagsComment": "Bunlar deneysel ayarlardır ve gelecekteki sürümlerde kaldırılabilir"
|
||||||
"downloadToml": "Yapılandırmayı İndir (TOML)"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"activity": {
|
"activity": {
|
||||||
|
|
@ -694,12 +760,17 @@
|
||||||
"totalScanned": "Toplam Taranan Klasör Sayısı",
|
"totalScanned": "Toplam Taranan Klasör Sayısı",
|
||||||
"quickScan": "Hızlı Tarama",
|
"quickScan": "Hızlı Tarama",
|
||||||
"fullScan": "Tam Tarama",
|
"fullScan": "Tam Tarama",
|
||||||
|
"selectiveScan": "Seçmeli",
|
||||||
"serverUptime": "Sunucu Çalışma Süresi",
|
"serverUptime": "Sunucu Çalışma Süresi",
|
||||||
"serverDown": "ÇEVRİMDIŞI",
|
"serverDown": "ÇEVRİMDIŞI",
|
||||||
"scanType": "Tür",
|
"scanType": "Tür",
|
||||||
"status": "Tarama Hatası",
|
"status": "Tarama Hatası",
|
||||||
"elapsedTime": "Geçen Süre",
|
"elapsedTime": "Geçen Süre"
|
||||||
"selectiveScan": "Seçmeli"
|
},
|
||||||
|
"nowPlaying": {
|
||||||
|
"title": "Şu An Çalıyor",
|
||||||
|
"empty": "Çalan şarkı yok",
|
||||||
|
"minutesAgo": "%{smart_count} dakika önce"
|
||||||
},
|
},
|
||||||
"help": {
|
"help": {
|
||||||
"title": "Navidrome Kısayolları",
|
"title": "Navidrome Kısayolları",
|
||||||
|
|
@ -709,15 +780,10 @@
|
||||||
"toggle_play": "Oynat / Duraklat",
|
"toggle_play": "Oynat / Duraklat",
|
||||||
"prev_song": "Önceki Şarkı",
|
"prev_song": "Önceki Şarkı",
|
||||||
"next_song": "Sonraki Şarkı",
|
"next_song": "Sonraki Şarkı",
|
||||||
|
"current_song": "Mevcut Şarkıya Git",
|
||||||
"vol_up": "Sesi Arttır",
|
"vol_up": "Sesi Arttır",
|
||||||
"vol_down": "Sesi Azalt",
|
"vol_down": "Sesi Azalt",
|
||||||
"toggle_love": "Bu şarkıyı favorilere ekle",
|
"toggle_love": "Bu şarkıyı favorilere ekle"
|
||||||
"current_song": "Mevcut Şarkıya Git"
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"nowPlaying": {
|
|
||||||
"title": "Şu An Çalıyor",
|
|
||||||
"empty": "Çalan şarkı yok",
|
|
||||||
"minutesAgo": "%{smart_count} dakika önce"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -218,7 +218,7 @@ additional:
|
||||||
aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ]
|
aliases: [ grp1, grouping, ©grp, wm/contentgroupdescription ]
|
||||||
album: true
|
album: true
|
||||||
key:
|
key:
|
||||||
aliases: [ tkey, key, ----:com.apple.itunes:initialkey, wm/initialkey ]
|
aliases: [ tkey, key, initialkey, ----:com.apple.itunes:initialkey, wm/initialkey ]
|
||||||
isrc:
|
isrc:
|
||||||
aliases: [ tsrc, isrc, ----:com.apple.itunes:isrc, wm/isrc ]
|
aliases: [ tsrc, isrc, ----:com.apple.itunes:isrc, wm/isrc ]
|
||||||
language:
|
language:
|
||||||
|
|
@ -231,9 +231,9 @@ additional:
|
||||||
movementname:
|
movementname:
|
||||||
aliases: [ mvnm, movementname, ©mvn ]
|
aliases: [ mvnm, movementname, ©mvn ]
|
||||||
movementtotal:
|
movementtotal:
|
||||||
aliases: [ movementtotal, mvc ]
|
aliases: [ movementtotal, movementcount, mvc ]
|
||||||
movement:
|
movement:
|
||||||
aliases: [ mvin, movement, mvi ]
|
aliases: [ mvin, movement, movementnumber, mvi ]
|
||||||
recordlabel:
|
recordlabel:
|
||||||
aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ]
|
aliases: [ tpub, label, publisher, ----:com.apple.itunes:label, wm/publisher, organization ]
|
||||||
album: true
|
album: true
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrAlreadyScanning = errors.New("already scanning")
|
ErrAlreadyScanning = model.ErrAlreadyScanning
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(rootCtx context.Context, ds model.DataStore, broker events.Broker,
|
func New(rootCtx context.Context, ds model.DataStore, broker events.Broker,
|
||||||
|
|
@ -304,14 +304,14 @@ func LockForMaintenance() (func(), bool) {
|
||||||
return scanMaintenanceMux.Unlock, true
|
return scanMaintenanceMux.Unlock, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// EffectiveFullScan reports whether a scan was requested as full or will resume an interrupted
|
// EffectiveFullScan reports whether a scan was requested as full, will resume an interrupted full scan,
|
||||||
// full scan in one of the included libraries.
|
// or will rescan a library in full because its PID config changed, in one of the included libraries.
|
||||||
func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool {
|
func EffectiveFullScan(ctx context.Context, ds model.DataStore, fullScan bool, targets []model.ScanTarget) bool {
|
||||||
if fullScan {
|
if fullScan {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool {
|
return anyIncludedLibrary(ctx, ds, targets, func(library model.Library) bool {
|
||||||
return library.FullScanInProgress
|
return library.FullScanInProgress || library.NeedsPIDRescan()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package scanner_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf/configtest"
|
"github.com/navidrome/navidrome/conf/configtest"
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
|
|
@ -70,14 +71,21 @@ var _ = Describe("EffectiveFullScan", func() {
|
||||||
var ds *tests.MockDataStore
|
var ds *tests.MockDataStore
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
pid := model.Library{}.EffectivePID()
|
||||||
libraries := &tests.MockLibraryRepo{}
|
libraries := &tests.MockLibraryRepo{}
|
||||||
libraries.SetData(model.Libraries{
|
libraries.SetData(model.Libraries{
|
||||||
{ID: 1, FullScanInProgress: true},
|
{ID: 1, FullScanInProgress: true, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
||||||
{ID: 2},
|
{ID: 2, ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
||||||
|
{ID: 3, LastScanAt: time.Now(), PIDAlbum: "folder", ScannedPIDAlbum: pid.Album, ScannedPIDTrack: pid.Track},
|
||||||
})
|
})
|
||||||
ds = &tests.MockDataStore{MockedLibrary: libraries}
|
ds = &tests.MockDataStore{MockedLibrary: libraries}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("detects a library that needs a full rescan for a PID change", func() {
|
||||||
|
targets := []model.ScanTarget{{LibraryID: 3, FolderPath: "."}}
|
||||||
|
Expect(scanner.EffectiveFullScan(GinkgoT().Context(), ds, false, targets)).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
It("detects an interrupted full scan in a targeted library", func() {
|
It("detects an interrupted full scan in a targeted library", func() {
|
||||||
targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}}
|
targets := []model.ScanTarget{{LibraryID: 1, FolderPath: "."}}
|
||||||
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue())
|
Expect(scanner.EffectiveFullScan(context.Background(), ds, false, targets)).To(BeTrue())
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStor
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
|
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
|
||||||
state.sendError(err)
|
state.sendError(err)
|
||||||
|
state.markFailed(lib.ID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
jobs = append(jobs, job)
|
jobs = append(jobs, job)
|
||||||
|
|
@ -55,6 +56,7 @@ type scanJob struct {
|
||||||
fs storage.MusicFS
|
fs storage.MusicFS
|
||||||
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
|
lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
|
||||||
targetFolders []string // Specific folders to scan (including all descendants)
|
targetFolders []string // Specific folders to scan (including all descendants)
|
||||||
|
prevAlbumPIDConf string // Album PID spec of the last finished scan, only when it differs from the current one
|
||||||
lock sync.Mutex
|
lock sync.Mutex
|
||||||
numFolders atomic.Int64
|
numFolders atomic.Int64
|
||||||
}
|
}
|
||||||
|
|
@ -77,6 +79,21 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full
|
||||||
return nil, fmt.Errorf("getting fs for library: %w", err)
|
return nil, fmt.Errorf("getting fs for library: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pid := lib.EffectivePID()
|
||||||
|
if lib.NeedsPIDRescan() {
|
||||||
|
msg := "Scanner: PID config changed, rescanning library in full"
|
||||||
|
if len(targetFolders) > 0 {
|
||||||
|
msg = "Scanner: PID config changed, rescanning target folders in full"
|
||||||
|
}
|
||||||
|
log.Info(ctx, msg, "lib", lib.Name, "targetFolders", targetFolders,
|
||||||
|
"album", pid.Album, "track", pid.Track, "scannedAlbum", lib.ScannedPIDAlbum, "scannedTrack", lib.ScannedPIDTrack)
|
||||||
|
fullScan = true
|
||||||
|
}
|
||||||
|
var prevAlbumPIDConf string
|
||||||
|
if lib.ScannedPIDAlbum != pid.Album {
|
||||||
|
prevAlbumPIDConf = lib.ScannedPIDAlbum
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure FullScanInProgress reflects the current scan request.
|
// Ensure FullScanInProgress reflects the current scan request.
|
||||||
// This is important when resuming an interrupted quick scan as a full scan:
|
// This is important when resuming an interrupted quick scan as a full scan:
|
||||||
// the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly.
|
// the DB may have FullScanInProgress=false, but we need it true for isOutdated() to work correctly.
|
||||||
|
|
@ -87,6 +104,7 @@ func newScanJob(ctx context.Context, ds model.DataStore, lib model.Library, full
|
||||||
fs: fsys,
|
fs: fsys,
|
||||||
lastUpdates: lastUpdates,
|
lastUpdates: lastUpdates,
|
||||||
targetFolders: targetFolders,
|
targetFolders: targetFolders,
|
||||||
|
prevAlbumPIDConf: prevAlbumPIDConf,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +146,6 @@ type phaseFolders struct {
|
||||||
walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early
|
walkCtx context.Context //nolint:containedctx // cancelled when a folder fails to persist, so the walk stops early
|
||||||
stopWalk context.CancelCauseFunc
|
stopWalk context.CancelCauseFunc
|
||||||
state *scanState
|
state *scanState
|
||||||
prevAlbumPIDConf string
|
|
||||||
imageChanges *imageChangeCollector
|
imageChanges *imageChangeCollector
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,12 +155,6 @@ func (p *phaseFolders) description() string {
|
||||||
|
|
||||||
func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
|
func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
|
||||||
return ppl.NewProducer(func(put func(entry *folderEntry)) error {
|
return ppl.NewProducer(func(put func(entry *folderEntry)) error {
|
||||||
var err error
|
|
||||||
p.prevAlbumPIDConf, err = p.ds.Property().DefaultGet(p.ctx, consts.PIDAlbumKey, "")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("getting album PID conf: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO Parallelize multiple job when we have multiple libraries
|
// TODO Parallelize multiple job when we have multiple libraries
|
||||||
var total int64
|
var total int64
|
||||||
var totalChanged int64
|
var totalChanged int64
|
||||||
|
|
@ -173,7 +184,7 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
|
||||||
|
|
||||||
// Check if folder is outdated
|
// Check if folder is outdated
|
||||||
if folder.isOutdated() {
|
if folder.isOutdated() {
|
||||||
if !p.state.fullScan {
|
if !folder.job.lib.FullScanInProgress {
|
||||||
// Ancestor folders need a row even with no files of their own: artwork
|
// Ancestor folders need a row even with no files of their own: artwork
|
||||||
// resolution climbs them, and an image added later needs a state to diff.
|
// resolution climbs them, and an image added later needs a state to diff.
|
||||||
if folder.isEmpty() && folder.isNew() {
|
if folder.isEmpty() && folder.isNew() {
|
||||||
|
|
@ -239,7 +250,7 @@ func (p *phaseFolders) processFolder(entry *folderEntry) (*folderEntry, error) {
|
||||||
for afPath, af := range entry.audioFiles {
|
for afPath, af := range entry.audioFiles {
|
||||||
fullPath := path.Join(entry.path, afPath)
|
fullPath := path.Join(entry.path, afPath)
|
||||||
dbTrack, foundInDB := dbTracks[fullPath]
|
dbTrack, foundInDB := dbTracks[fullPath]
|
||||||
if !foundInDB || p.state.fullScan {
|
if !foundInDB || entry.job.lib.FullScanInProgress {
|
||||||
filesToImport[fullPath] = dbTrack
|
filesToImport[fullPath] = dbTrack
|
||||||
} else {
|
} else {
|
||||||
info, err := af.Info()
|
info, err := af.Info()
|
||||||
|
|
@ -289,18 +300,18 @@ func (p *phaseFolders) loadTagsFromFiles(entry *folderEntry, toImport map[string
|
||||||
}
|
}
|
||||||
for filePath, info := range allInfo {
|
for filePath, info := range allInfo {
|
||||||
md := metadata.New(filePath, info)
|
md := metadata.New(filePath, info)
|
||||||
track := md.ToMediaFile(entry.job.lib.ID, entry.id)
|
track := md.ToMediaFile(entry.job.lib, entry.id)
|
||||||
tracks = append(tracks, track)
|
tracks = append(tracks, track)
|
||||||
for _, t := range track.Tags.FlattenAll() {
|
for _, t := range track.Tags.FlattenAll() {
|
||||||
uniqueTags[t.ID] = t
|
uniqueTags[t.ID] = t
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep track of any album ID changes, to reassign annotations later
|
// Keep track of any album ID changes, to reassign annotations later
|
||||||
prevAlbumID := ""
|
prevAlbumID := track.AlbumID
|
||||||
if prev := toImport[filePath]; prev != nil {
|
if prev := toImport[filePath]; prev != nil {
|
||||||
prevAlbumID = prev.AlbumID
|
prevAlbumID = prev.AlbumID
|
||||||
} else {
|
} else if entry.job.prevAlbumPIDConf != "" {
|
||||||
prevAlbumID = md.AlbumID(track, p.prevAlbumPIDConf)
|
prevAlbumID = md.AlbumID(track, entry.job.prevAlbumPIDConf)
|
||||||
}
|
}
|
||||||
_, ok := entry.albumIDMap[track.AlbumID]
|
_, ok := entry.albumIDMap[track.AlbumID]
|
||||||
if prevAlbumID != track.AlbumID && !ok {
|
if prevAlbumID != track.AlbumID && !ok {
|
||||||
|
|
@ -453,7 +464,7 @@ func (p *phaseFolders) persistFolder(ctx context.Context, tx model.DataStore, en
|
||||||
if len(queueItems) > 0 {
|
if len(queueItems) > 0 {
|
||||||
queue := tx.ArtworkQueue()
|
queue := tx.ArtworkQueue()
|
||||||
enqueue := queue.Enqueue
|
enqueue := queue.Enqueue
|
||||||
if p.state.fullScan {
|
if entry.job.lib.FullScanInProgress {
|
||||||
enqueue = queue.EnqueueIfMissing
|
enqueue = queue.EnqueueIfMissing
|
||||||
}
|
}
|
||||||
if err := enqueue(ctx, queueItems...); err != nil {
|
if err := enqueue(ctx, queueItems...); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -4,18 +4,15 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||||
"github.com/navidrome/navidrome/conf"
|
|
||||||
"github.com/navidrome/navidrome/consts"
|
"github.com/navidrome/navidrome/consts"
|
||||||
"github.com/navidrome/navidrome/core/playlists"
|
"github.com/navidrome/navidrome/core/playlists"
|
||||||
"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/run"
|
"github.com/navidrome/navidrome/utils/run"
|
||||||
"github.com/navidrome/navidrome/utils/slice"
|
"github.com/navidrome/navidrome/utils/slice"
|
||||||
)
|
)
|
||||||
|
|
@ -33,6 +30,7 @@ type scanState struct {
|
||||||
libraries model.Libraries // Store libraries list for consistency across phases
|
libraries model.Libraries // Store libraries list for consistency across phases
|
||||||
targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans
|
targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans
|
||||||
totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection
|
totalLibraryCount int // Total number of libraries (unfiltered), for cross-library move detection
|
||||||
|
failedLibs map[int]bool // Libraries that could not be scanned in this run
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *scanState) sendProgress(info *ProgressInfo) {
|
func (s *scanState) sendProgress(info *ProgressInfo) {
|
||||||
|
|
@ -49,29 +47,15 @@ func (s *scanState) sendWarning(msg string) {
|
||||||
s.sendProgress(&ProgressInfo{Warning: msg})
|
s.sendProgress(&ProgressInfo{Warning: msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *scanState) sendError(err error) {
|
func (s *scanState) markFailed(libID int) {
|
||||||
s.sendProgress(&ProgressInfo{Error: err.Error()})
|
if s.failedLibs == nil {
|
||||||
|
s.failedLibs = map[int]bool{}
|
||||||
|
}
|
||||||
|
s.failedLibs[libID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// libraryRelativePath rebases an absolute scan target path onto the library root, since the
|
func (s *scanState) sendError(err error) {
|
||||||
// scanner's fs.FS only accepts paths relative to it. Relative paths, and absolute paths outside
|
s.sendProgress(&ProgressInfo{Error: err.Error()})
|
||||||
// the library root, are returned unchanged.
|
|
||||||
func libraryRelativePath(libPath, folderPath string) string {
|
|
||||||
if !filepath.IsAbs(folderPath) {
|
|
||||||
return folderPath
|
|
||||||
}
|
|
||||||
// The library root may be relative (e.g. the default "./music"); it must be made absolute
|
|
||||||
// to match against an absolute target, and it resolves against the same cwd as the scanner's fs.
|
|
||||||
absLib, err := filepath.Abs(libPath)
|
|
||||||
if err != nil {
|
|
||||||
return folderPath
|
|
||||||
}
|
|
||||||
rel, err := utils.RelPath(absLib, folderPath)
|
|
||||||
if err != nil || !filepath.IsLocal(rel) {
|
|
||||||
return folderPath
|
|
||||||
}
|
|
||||||
// The scanner's fs.FS is an io/fs, which always uses forward slashes.
|
|
||||||
return filepath.ToSlash(rel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
|
func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
|
||||||
|
|
@ -105,7 +89,7 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
|
||||||
})
|
})
|
||||||
|
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
folderPath := libraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
|
folderPath := model.LibraryRelativePath(libPaths[target.LibraryID], target.FolderPath)
|
||||||
if folderPath == "" {
|
if folderPath == "" {
|
||||||
folderPath = "."
|
folderPath = "."
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +122,10 @@ func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []
|
||||||
// if there was a full scan in progress, force a full scan
|
// if there was a full scan in progress, force a full scan
|
||||||
if !state.fullScan {
|
if !state.fullScan {
|
||||||
for _, lib := range state.libraries {
|
for _, lib := range state.libraries {
|
||||||
|
// A pending PID rescan already restarts in full through its own job
|
||||||
|
if lib.NeedsPIDRescan() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if lib.FullScanInProgress {
|
if lib.FullScanInProgress {
|
||||||
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
|
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
|
||||||
state.fullScan = true
|
state.fullScan = true
|
||||||
|
|
@ -216,10 +204,13 @@ func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanSt
|
||||||
var successfulLibs []model.Library
|
var successfulLibs []model.Library
|
||||||
|
|
||||||
for _, lib := range state.libraries {
|
for _, lib := range state.libraries {
|
||||||
if lib.LastScanStartedAt.IsZero() {
|
// A library with a changed PID config restarts its scan: resuming would skip the folders that
|
||||||
|
// the interrupted scan already processed with the old config
|
||||||
|
pidRescan := lib.NeedsPIDRescan()
|
||||||
|
if lib.LastScanStartedAt.IsZero() || pidRescan {
|
||||||
// This is a new scan - mark it as started
|
// This is a new scan - mark it as started
|
||||||
err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
err := s.ds.WithTxRetry(ctx, func(ctx context.Context, tx model.DataStore) error {
|
||||||
return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan)
|
return tx.Library().ScanBegin(ctx, lib.ID, state.fullScan || pidRescan)
|
||||||
}, "scanner: begin library scan")
|
}, "scanner: begin library scan")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
|
log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
|
||||||
|
|
@ -341,11 +332,12 @@ func (s *scannerImpl) runUpdateLibraries(ctx context.Context, state *scanState)
|
||||||
if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil {
|
if err := tx.Library().ScanEnd(ctx, lib.ID); err != nil {
|
||||||
return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err)
|
return fmt.Errorf("updating last scan completed for %s: %w", lib.Name, err)
|
||||||
}
|
}
|
||||||
if err := tx.Property().Put(ctx, consts.PIDTrackKey, conf.Server.PID.Track); err != nil {
|
// A selective scan covers only part of the library, so the rest may still use the old PID
|
||||||
return fmt.Errorf("updating track PID conf: %w", err)
|
// config. A library that could not be scanned did not apply it either.
|
||||||
|
if !state.isSelectiveScan() && !state.failedLibs[lib.ID] {
|
||||||
|
if err := tx.Library().SetScannedPID(ctx, lib.ID, lib.EffectivePID()); err != nil {
|
||||||
|
return fmt.Errorf("updating PID conf for %s: %w", lib.Name, err)
|
||||||
}
|
}
|
||||||
if err := tx.Property().Put(ctx, consts.PIDAlbumKey, conf.Server.PID.Album); err != nil {
|
|
||||||
return fmt.Errorf("updating album PID conf: %w", err)
|
|
||||||
}
|
}
|
||||||
if state.changesDetected.Load() {
|
if state.changesDetected.Load() {
|
||||||
log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name)
|
log.Debug(ctx, "Scanner: Refreshing library stats", "lib", lib.Name)
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ package scanner
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
ppl "github.com/google/go-pipeline/pkg/pipeline"
|
||||||
|
|
@ -13,43 +11,6 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ = Describe("libraryRelativePath", func() {
|
|
||||||
// Paths are built with filepath so the "absolute" cases stay absolute on every OS
|
|
||||||
// (a Unix-style "/foo" is not absolute on Windows).
|
|
||||||
libRoot, _ := filepath.Abs(filepath.Join("jukebox", "collection"))
|
|
||||||
outside, _ := filepath.Abs(filepath.Join("somewhere", "else"))
|
|
||||||
|
|
||||||
It("returns a relative path unchanged", func() {
|
|
||||||
Expect(libraryRelativePath(libRoot, "_Collection")).To(Equal("_Collection"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rebases an absolute target when the library root is relative", func() {
|
|
||||||
cwd, err := os.Getwd()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
Expect(libraryRelativePath(filepath.Join("music", "library"), filepath.Join(cwd, "music", "library", "rock"))).To(Equal("rock"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rebases an absolute path that equals the library root to '.'", func() {
|
|
||||||
Expect(libraryRelativePath(libRoot, libRoot)).To(Equal("."))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("rebases an absolute path under the library root", func() {
|
|
||||||
Expect(libraryRelativePath(libRoot, filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("handles a trailing slash on the library path", func() {
|
|
||||||
Expect(libraryRelativePath(libRoot+string(filepath.Separator), filepath.Join(libRoot, "_Collection"))).To(Equal("_Collection"))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("leaves an absolute path outside the library root unchanged", func() {
|
|
||||||
Expect(libraryRelativePath(libRoot, outside)).To(Equal(outside))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("returns an empty path unchanged", func() {
|
|
||||||
Expect(libraryRelativePath(libRoot, "")).To(Equal(""))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
type mockPhase struct {
|
type mockPhase struct {
|
||||||
num int
|
num int
|
||||||
produceFunc func() ppl.Producer[int]
|
produceFunc func() ppl.Producer[int]
|
||||||
|
|
|
||||||
|
|
@ -835,4 +835,170 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
|
||||||
Expect(lastError).To(BeEmpty())
|
Expect(lastError).To(BeEmpty())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Context("Per-library PID config", func() {
|
||||||
|
albumsOf := func(libID int) model.Albums {
|
||||||
|
// The mock datastore's GC is a no-op, so run the real one to purge the albums left
|
||||||
|
// empty by a regroup, as the scanner does in production
|
||||||
|
Expect(ds.RealDS.GC(ctx)).To(Succeed())
|
||||||
|
albums, err := ds.Album().GetAll(ctx, model.QueryOptions{
|
||||||
|
Filters: squirrel.Eq{"library_id": libID, "missing": false},
|
||||||
|
Sort: "name",
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return albums
|
||||||
|
}
|
||||||
|
trackByTitle := func(libID int, title string) model.MediaFile {
|
||||||
|
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{
|
||||||
|
Filters: squirrel.Eq{"library_id": libID, "title": title},
|
||||||
|
})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(mfs).To(HaveLen(1))
|
||||||
|
return mfs[0]
|
||||||
|
}
|
||||||
|
rockTitles := func() []string {
|
||||||
|
mfs, err := ds.MediaFile().GetAll(ctx, model.QueryOptions{Filters: squirrel.Eq{"library_id": lib1.ID}})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return slice.Map(mfs, func(mf model.MediaFile) string { return mf.Title })
|
||||||
|
}
|
||||||
|
// changeRockInDB edits the rock track in the DB only. A full rescan of the rock library would
|
||||||
|
// restore the title from the file tags, a quick scan leaves it alone.
|
||||||
|
changeRockInDB := func() {
|
||||||
|
_, err := db.Db().ExecContext(ctx, "update media_file set title = 'Changed In DB' where library_id = ?", lib1.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
}
|
||||||
|
// changeBlueTrainInDB does the same for one jazz track
|
||||||
|
changeBlueTrainInDB := func() {
|
||||||
|
_, err := db.Db().ExecContext(ctx, "update media_file set title = 'Blue Train In DB' where library_id = ? and title = 'Blue Train'", lib2.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
}
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
beatles := template(_t{"albumartist": "The Beatles", "album": "Abbey Road", "year": 1969})
|
||||||
|
_ = createFS("rock", fstest.MapFS{
|
||||||
|
"The Beatles/Abbey Road/01 - Come Together.mp3": beatles(track(1, "Come Together")),
|
||||||
|
})
|
||||||
|
|
||||||
|
miles := template(_t{"albumartist": "Miles Davis", "album": "Kind of Blue", "year": 1959})
|
||||||
|
coltrane := template(_t{"albumartist": "John Coltrane", "album": "Giant Steps", "year": 1960})
|
||||||
|
blueTrain := template(_t{"albumartist": "John Coltrane", "album": "Blue Train", "year": 1957})
|
||||||
|
_ = createFS("jazz", fstest.MapFS{
|
||||||
|
"Loose/01 - So What.mp3": miles(track(1, "So What")),
|
||||||
|
"Loose/02 - Giant Steps.mp3": coltrane(track(1, "Giant Steps")),
|
||||||
|
"Coltrane/Blue Train/01 - Blue Train.mp3": blueTrain(track(1, "Blue Train")),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
It("regroups only the library whose PID config changed, keeping annotations", func() {
|
||||||
|
Expect(runScanner(ctx, true)).To(Succeed())
|
||||||
|
Expect(albumsOf(lib2.ID)).To(HaveLen(3))
|
||||||
|
|
||||||
|
// Star Blue Train, to check the star follows the album to its new ID
|
||||||
|
oldBlueTrain := trackByTitle(lib2.ID, "Blue Train")
|
||||||
|
Expect(ds.Album().SetStar(ctx, true, oldBlueTrain.AlbumID)).To(Succeed())
|
||||||
|
changeRockInDB()
|
||||||
|
|
||||||
|
lib2.PIDAlbum = "folder"
|
||||||
|
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
|
||||||
|
Expect(runScanner(ctx, false)).To(Succeed())
|
||||||
|
|
||||||
|
// Jazz is grouped by folder now: "Loose" is one album
|
||||||
|
Expect(albumsOf(lib2.ID)).To(HaveLen(2))
|
||||||
|
Expect(trackByTitle(lib2.ID, "So What").AlbumID).To(Equal(trackByTitle(lib2.ID, "Giant Steps").AlbumID))
|
||||||
|
|
||||||
|
newBlueTrain := trackByTitle(lib2.ID, "Blue Train")
|
||||||
|
Expect(newBlueTrain.AlbumID).ToNot(Equal(oldBlueTrain.AlbumID))
|
||||||
|
album, err := ds.Album().Get(ctx, newBlueTrain.AlbumID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(album.Starred).To(BeTrue())
|
||||||
|
|
||||||
|
// Rock only got a quick scan
|
||||||
|
Expect(rockTitles()).To(ConsistOf("Changed In DB"))
|
||||||
|
|
||||||
|
jazz, err := ds.Library().Get(ctx, lib2.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(jazz.ScannedPIDAlbum).To(Equal("folder"))
|
||||||
|
Expect(jazz.PIDChanged()).To(BeFalse())
|
||||||
|
rock, err := ds.Library().Get(ctx, lib1.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(rock.PIDChanged()).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rescans only libraries that follow the global config", func() {
|
||||||
|
lib2.PIDAlbum = "folder"
|
||||||
|
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
|
||||||
|
Expect(runScanner(ctx, true)).To(Succeed())
|
||||||
|
changeRockInDB()
|
||||||
|
changeBlueTrainInDB()
|
||||||
|
|
||||||
|
conf.Server.PID.Album = "album"
|
||||||
|
Expect(runScanner(ctx, false)).To(Succeed())
|
||||||
|
|
||||||
|
// Rock follows the global config, so it was rescanned in full and its title restored
|
||||||
|
Expect(rockTitles()).To(ConsistOf("Come Together"))
|
||||||
|
// Jazz has its own override, so it only got a quick scan
|
||||||
|
trackByTitle(lib2.ID, "Blue Train In DB")
|
||||||
|
jazz, err := ds.Library().Get(ctx, lib2.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(jazz.ScannedPIDAlbum).To(Equal("folder"))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("restarts an interrupted scan when the PID config changed meanwhile", func() {
|
||||||
|
Expect(runScanner(ctx, true)).To(Succeed())
|
||||||
|
|
||||||
|
// Simulate a quick scan of jazz that was interrupted after it had processed every folder:
|
||||||
|
// the folders were updated after the (old) scan start time
|
||||||
|
_, err := db.Db().ExecContext(ctx, "update library set last_scan_started_at = ?, full_scan_in_progress = false where id = ?",
|
||||||
|
time.Now().Add(-time.Hour), lib2.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
lib2.PIDAlbum = "folder"
|
||||||
|
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
|
||||||
|
Expect(runScanner(ctx, false)).To(Succeed())
|
||||||
|
|
||||||
|
// Every folder was revisited with the new config
|
||||||
|
Expect(albumsOf(lib2.ID)).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not turn an interrupted PID rescan into a full scan of every library", func() {
|
||||||
|
Expect(runScanner(ctx, true)).To(Succeed())
|
||||||
|
changeRockInDB()
|
||||||
|
lib2.PIDAlbum = "folder"
|
||||||
|
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
|
||||||
|
|
||||||
|
// Simulate a PID full scan of jazz that was interrupted
|
||||||
|
Expect(ds.Library().ScanBegin(ctx, lib2.ID, true)).To(Succeed())
|
||||||
|
Expect(runScanner(ctx, false)).To(Succeed())
|
||||||
|
|
||||||
|
// Rock only got a quick scan, jazz was rescanned with the new config
|
||||||
|
Expect(rockTitles()).To(ConsistOf("Changed In DB"))
|
||||||
|
Expect(albumsOf(lib2.ID)).To(HaveLen(2))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not record the PID config for a library that could not be scanned", func() {
|
||||||
|
Expect(runScanner(ctx, true)).To(Succeed())
|
||||||
|
broken := model.Library{Name: "Broken", Path: "unregistered:///music", PIDAlbum: "folder"}
|
||||||
|
Expect(ds.Library().Put(ctx, &broken)).To(Succeed())
|
||||||
|
|
||||||
|
// The scan reports an error for the broken library, and still finishes the others
|
||||||
|
_ = runScanner(ctx, false)
|
||||||
|
|
||||||
|
reloaded, err := ds.Library().Get(ctx, broken.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(reloaded.PIDChanged()).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not record the PID config after a selective scan", func() {
|
||||||
|
Expect(runScanner(ctx, true)).To(Succeed())
|
||||||
|
lib2.PIDAlbum = "folder"
|
||||||
|
Expect(ds.Library().Put(ctx, &lib2)).To(Succeed())
|
||||||
|
|
||||||
|
_, err := s.ScanFolders(ctx, false, []model.ScanTarget{{LibraryID: lib2.ID, FolderPath: "Loose"}})
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
jazz, err := ds.Library().Get(ctx, lib2.ID)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(jazz.PIDChanged()).To(BeTrue())
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,13 @@ func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A full walk never descends into symlinked folders when following is disabled, so a
|
||||||
|
// target reached through one (e.g. a watcher event for a new link) is skipped too.
|
||||||
|
if !conf.Server.Scanner.FollowSymlinks && isSymlinkedPath(job.fs, folderPath) {
|
||||||
|
log.Debug(ctx, "Scanner: Skipping symlinked target folder, following is disabled", "path", folderPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Create checker and push patterns from root to this folder
|
// Create checker and push patterns from root to this folder
|
||||||
checker := newIgnoreChecker(job.fs)
|
checker := newIgnoreChecker(job.fs)
|
||||||
err = checker.PushAllParents(ctx, folderPath)
|
err = checker.PushAllParents(ctx, folderPath)
|
||||||
|
|
@ -225,6 +232,18 @@ func isDirOrSymlinkToDir(fsys fs.FS, baseDir string, dirEnt fs.DirEntry) (bool,
|
||||||
return fileInfo.IsDir(), nil
|
return fileInfo.IsDir(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSymlinkedPath returns true if folderPath, or any of its parent folders, is a symbolic link.
|
||||||
|
// It needs fsys to implement fs.ReadLinkFS, otherwise links are followed and never detected.
|
||||||
|
func isSymlinkedPath(fsys fs.FS, folderPath string) bool {
|
||||||
|
for p := path.Clean(folderPath); p != "." && p != "/"; p = path.Dir(p) {
|
||||||
|
info, err := fs.Lstat(fsys, p)
|
||||||
|
if err == nil && info.Mode()&fs.ModeSymlink != 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const maxSymlinkHops = 40
|
const maxSymlinkHops = 40
|
||||||
|
|
||||||
// resolveEntryName returns the name to classify the entry by, and whether to
|
// resolveEntryName returns the name to classify the entry by, and whether to
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -260,6 +262,44 @@ var _ = Describe("walk_dir_tree", func() {
|
||||||
// Folders not in targets should remain in lastUpdates
|
// Folders not in targets should remain in lastUpdates
|
||||||
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
|
Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// #6292: a watcher event for a new folder symlink makes the link itself a scan target
|
||||||
|
Context("symlinked target folders (production local storage FS)", func() {
|
||||||
|
BeforeEach(func() {
|
||||||
|
libRoot := GinkgoT().TempDir()
|
||||||
|
Expect(os.MkdirAll(filepath.Join(libRoot, "Mozart", "Album1"), 0755)).To(Succeed())
|
||||||
|
Expect(os.WriteFile(filepath.Join(libRoot, "Mozart", "Album1", "track.mp3"), []byte("AUDIO"), 0600)).To(Succeed())
|
||||||
|
Expect(os.Symlink("Mozart", filepath.Join(libRoot, "Wolfgang Amadeus Mozart"))).To(Succeed())
|
||||||
|
job = &scanJob{fs: newLocalMusicFS(libRoot), lib: model.Library{Path: libRoot}}
|
||||||
|
})
|
||||||
|
|
||||||
|
walkTargets := func(targets ...string) map[string]*folderEntry {
|
||||||
|
results, err := walkDirTree(ctx, job, targets...)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
folders := map[string]*folderEntry{}
|
||||||
|
for folder := range results {
|
||||||
|
folders[folder.path] = folder
|
||||||
|
}
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
DescribeTable("with FollowSymlinks disabled",
|
||||||
|
func(target string, expected ...string) {
|
||||||
|
conf.Server.Scanner.FollowSymlinks = false
|
||||||
|
Expect(slices.Collect(maps.Keys(walkTargets(target)))).To(ConsistOf(expected))
|
||||||
|
},
|
||||||
|
Entry("skips a target that is a symlink", "Wolfgang Amadeus Mozart"),
|
||||||
|
Entry("skips a target under a symlinked folder", "Wolfgang Amadeus Mozart/Album1"),
|
||||||
|
Entry("walks a regular target", "Mozart", "Mozart", "Mozart/Album1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("walks a symlinked target when FollowSymlinks is enabled", func() {
|
||||||
|
conf.Server.Scanner.FollowSymlinks = true
|
||||||
|
folders := walkTargets("Wolfgang Amadeus Mozart")
|
||||||
|
Expect(folders).To(HaveKey("Wolfgang Amadeus Mozart/Album1"))
|
||||||
|
Expect(folders["Wolfgang Amadeus Mozart/Album1"].audioFiles).To(HaveKey("track.mp3"))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -433,8 +473,8 @@ var _ = Describe("walk_dir_tree", func() {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Regression for #5752: the production localFS must resolve file symlinks.
|
// Regression for #5752: the production localFS must resolve file symlinks.
|
||||||
// It wraps os.DirFS behind the fs.FS interface, so fs.ReadLink-based
|
// fs.ReadLink-based resolution can't follow targets outside the library
|
||||||
// resolution is not available and full OS-level resolution is required.
|
// root, so full OS-level resolution is required.
|
||||||
Context("production local storage FS", func() {
|
Context("production local storage FS", func() {
|
||||||
var libRoot string
|
var libRoot string
|
||||||
var musicFS storage.MusicFS
|
var musicFS storage.MusicFS
|
||||||
|
|
@ -460,12 +500,7 @@ var _ = Describe("walk_dir_tree", func() {
|
||||||
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
|
Expect(os.Symlink(filepath.Join(pool, "mid.wav"), filepath.Join(libRoot, "evil.wav"))).To(Succeed())
|
||||||
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
|
Expect(os.Symlink(filepath.Join(pool, "missing.mp3"), filepath.Join(libRoot, "broken.mp3"))).To(Succeed())
|
||||||
|
|
||||||
u, err := storage.LocalPathToURL(libRoot)
|
musicFS = newLocalMusicFS(libRoot)
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
s, err := storage.For(u.String())
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
musicFS, err = s.FS()
|
|
||||||
Expect(err).ToNot(HaveOccurred())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
walkRoot := func() *folderEntry {
|
walkRoot := func() *folderEntry {
|
||||||
|
|
@ -700,6 +735,17 @@ func getDirEntry(baseDir, name string) os.DirEntry {
|
||||||
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
|
panic(fmt.Sprintf("Could not find %s in %s", name, baseDir))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newLocalMusicFS returns the production local storage MusicFS rooted at libRoot
|
||||||
|
func newLocalMusicFS(libRoot string) storage.MusicFS {
|
||||||
|
u, err := storage.LocalPathToURL(libRoot)
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
s, err := storage.For(u.String())
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
musicFS, err := s.FS()
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
return musicFS
|
||||||
|
}
|
||||||
|
|
||||||
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
|
// mockMusicFS is a mock implementation of the MusicFS interface that supports symlinks
|
||||||
type mockMusicFS struct {
|
type mockMusicFS struct {
|
||||||
storage.MusicFS
|
storage.MusicFS
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ import (
|
||||||
"github.com/navidrome/navidrome/core/storage"
|
"github.com/navidrome/navidrome/core/storage"
|
||||||
"github.com/navidrome/navidrome/log"
|
"github.com/navidrome/navidrome/log"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/utils"
|
|
||||||
"github.com/navidrome/navidrome/utils/singleton"
|
"github.com/navidrome/navidrome/utils/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -246,7 +245,7 @@ func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library,
|
||||||
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
|
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
|
||||||
return nil
|
return nil
|
||||||
case path := <-events:
|
case path := <-events:
|
||||||
path, err := utils.RelPath(absLibPath, path)
|
path, err := filepath.Rel(absLibPath, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
|
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -158,8 +158,8 @@ and `MusicArtist` queries accept it too, matching artists credited on an album o
|
||||||
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
|
`Years`; `StudioIds` (record labels, as listed by `GET Studios`); `SearchTerm`;
|
||||||
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
|
`Filters` (`IsFavorite`, `IsFavoriteOrLikes`, `IsPlayed`, `IsUnplayed`) and the standalone
|
||||||
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
|
`isFavorite`/`isPlayed` booleans it can also be expressed as — `Filters` wins when both are sent, as
|
||||||
in Jellyfin; `Likes`, `Dislikes`, `IsFolder`, `IsNotFolder` and `IsResumable` have no Navidrome
|
in Jellyfin; `Likes` and `Dislikes` are not supported, and `IsFolder`, `IsNotFolder` and
|
||||||
equivalent and are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
`IsResumable` have no Navidrome equivalent, so all five are ignored; `SortBy`/`SortOrder` (every recognized key is applied in order, so secondary keys break ties;
|
||||||
unrecognized keys are skipped, and `Random` always sorts alone);
|
unrecognized keys are skipped, and `Random` always sorts alone);
|
||||||
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
|
`StartIndex`/`Limit`; and `Ids` (batch fetch by id). `Recursive=false` with a library `ParentId`
|
||||||
returns direct children only (no tracks — no track is a library's direct child).
|
returns direct children only (no tracks — no track is a library's direct child).
|
||||||
|
|
@ -176,7 +176,7 @@ returns direct children only (no tracks — no track is a library's direct child
|
||||||
| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` |
|
| Artists / genres / labels | `GET Artists`, `GET Artists/AlbumArtists`, `GET Genres`, `GET MusicGenres`, `GET Studios`, `GET Items/Filters` |
|
||||||
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
|
| Similar / mixes | `GET Artists/{itemId}/Similar`, `GET Items/{itemId}/Similar`, `GET Albums/{itemId}/Similar`, `GET {Items,Songs,Albums,Artists,Playlists}/{itemId}/InstantMix`, `GET Artists/InstantMix?id=`, `GET MusicGenres/InstantMix?id=` |
|
||||||
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
|
| Images | `GET Items/{itemId}/Images/{type}[/{index}]` (public), `POST`/`DELETE Items/{itemId}/Images/{type}` (playlist cover, authenticated) |
|
||||||
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET UserItems/{itemId}/UserData`, `GET Users/{userId}/Items/{itemId}/UserData` |
|
| Favorites / ratings for songs, albums, artists, and playlists | `POST`/`DELETE UserFavoriteItems/{itemId}`, `POST`/`DELETE Users/{userId}/FavoriteItems/{itemId}`, `POST`/`DELETE UserItems/{itemId}/Rating`, `POST`/`DELETE Users/{userId}/Items/{itemId}/Rating`, `GET`/`POST UserItems/{itemId}/UserData`, `GET`/`POST Users/{userId}/Items/{itemId}/UserData` |
|
||||||
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
|
| Streaming | `GET Audio/{itemId}/stream[.{container}]`, `GET Audio/{itemId}/universal`, `GET Audio/{itemId}/main.m3u8`, `GET Items/{itemId}/File`, `GET Items/{itemId}/Download`, `GET`/`POST Items/{itemId}/PlaybackInfo` (`HEAD` too on stream, universal, File, Download and images; a transcode HEAD answers without starting it) |
|
||||||
| Lyrics | `GET Audio/{itemId}/Lyrics` |
|
| Lyrics | `GET Audio/{itemId}/Lyrics` |
|
||||||
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` |
|
| Playback reporting | `POST Sessions/Playing`, `POST Sessions/Playing/Progress`, `POST Sessions/Playing/Stopped`, `POST Sessions/Playing/Ping` (no-op), `POST Sessions/Capabilities[/Full]` |
|
||||||
|
|
@ -402,12 +402,20 @@ make test PKG=./server/jellyfin/...
|
||||||
in the playlist, so ids change when entries are inserted or removed; re-read the list after an
|
in the playlist, so ids change when entries are inserted or removed; re-read the list after an
|
||||||
edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work;
|
edit. Real Jellyfin uses the song id. Clients that echo `PlaylistItemId` back (Finamp) work;
|
||||||
clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`.
|
clients that send the song id as `EntryIds` or to `Move` (Jellify) get `404`.
|
||||||
- **Rating uses the `rating` param.** `POST Users/{userId}/Items/{itemId}/Rating` reads a `rating`
|
- **Ratings are stored as 0-5 stars.** Jellyfin's 0-10 `Rating` is rounded to the nearest star
|
||||||
(0-10) and stores it as 0-5 stars. Jellyfin's `likes` boolean is not read, so a client that sends
|
(any nonzero value keeps at least one), so it reads back as an even number. `likes=true` stores
|
||||||
only `likes` clears the rating.
|
5 stars and `likes=false` 1 star, like Jellyfin's 10 and 1. A rating of 0 clears the rating.
|
||||||
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed), `Search/Hints` and the non-legacy
|
- **`POST .../Rating` needs a value.** Jellyfin clears the rating when `likes` is missing; Navidrome
|
||||||
`UserItems/{itemId}/Rating` are not implemented. Play counts only change through playback
|
answers `400` and keeps it, as it does for a malformed or out-of-range value. Use
|
||||||
reporting.
|
`DELETE .../Rating` to clear. A numeric `rating` param (0-10) is also accepted when `likes` is
|
||||||
|
absent.
|
||||||
|
- **`POST .../UserData` writes only `Rating`, `Likes` and `IsFavorite`.** `Played`, `PlayCount`,
|
||||||
|
`LastPlayedDate` and `PlaybackPositionTicks` are ignored. A body that is not a single JSON
|
||||||
|
object, or a `Rating` outside 0-10, is a `400` and nothing is written.
|
||||||
|
- **Annotation writes can't target another user.** Rating and UserData writes whose `userId` names
|
||||||
|
a different user get `403`, even for admins; Jellyfin lets admins edit other users' data.
|
||||||
|
- **Missing endpoints.** `UserPlayedItems` (mark played/unplayed) and `Search/Hints` are not
|
||||||
|
implemented. Play counts only change through playback reporting.
|
||||||
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
|
- **Some `Fields` are never emitted.** `ProviderIds`, `People`, `Etag` and `DateLastMediaAdded` are
|
||||||
accepted but never returned.
|
accepted but never returned.
|
||||||
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork
|
- **Blurhashes can be missing.** `ImageBlurHashes` carries the real blurhash that the artwork
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,52 @@
|
||||||
package jellyfin
|
package jellyfin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
"github.com/navidrome/navidrome/server/events"
|
"github.com/navidrome/navidrome/server/events"
|
||||||
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveAnnotated finds which annotated repo owns id, returning the resource name used in
|
type annotatedItem struct {
|
||||||
// refreshResource events. Albums and songs 404 when the user can't access their library; artists
|
repo model.AnnotatedRepository
|
||||||
// span libraries (library_artist), so have no single LibraryID to gate on and rely on list-time
|
resource string // name used in refreshResource events
|
||||||
// scoping. PlaylistRepository.Get enforces playlist visibility. When repo is nil the response has
|
ann model.Annotations
|
||||||
// already been written, so callers must return without writing the annotation.
|
}
|
||||||
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (repo model.AnnotatedRepository, resource string) {
|
|
||||||
|
// resolveAnnotated finds id's repo and the caller's annotations. Only albums and songs are library-gated
|
||||||
|
// (artists span libraries; playlists gate in Get). When !ok, the response has already been written.
|
||||||
|
func (api *Router) resolveAnnotated(w http.ResponseWriter, r *http.Request, id string) (item annotatedItem, ok bool) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
entity, err := model.GetEntityByID(ctx, api.ds, id)
|
||||||
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
if err != nil && !errors.Is(err, model.ErrNotFound) {
|
||||||
api.internalError(w, r, err)
|
api.internalError(w, r, err)
|
||||||
return nil, ""
|
return annotatedItem{}, false
|
||||||
}
|
}
|
||||||
u, _ := request.UserFrom(ctx)
|
u, _ := request.UserFrom(ctx)
|
||||||
switch e := entity.(type) {
|
switch e := entity.(type) {
|
||||||
case *model.Album:
|
case *model.Album:
|
||||||
if u.HasLibraryAccess(e.LibraryID) {
|
if u.HasLibraryAccess(e.LibraryID) {
|
||||||
return api.ds.Album(), "album"
|
return annotatedItem{api.ds.Album(), "album", e.Annotations}, true
|
||||||
}
|
}
|
||||||
case *model.Artist:
|
case *model.Artist:
|
||||||
return api.ds.Artist(), "artist"
|
return annotatedItem{api.ds.Artist(), "artist", e.Annotations}, true
|
||||||
case *model.MediaFile:
|
case *model.MediaFile:
|
||||||
if u.HasLibraryAccess(e.LibraryID) {
|
if u.HasLibraryAccess(e.LibraryID) {
|
||||||
return api.ds.MediaFile(), "song"
|
return annotatedItem{api.ds.MediaFile(), "song", e.Annotations}, true
|
||||||
}
|
}
|
||||||
case *model.Playlist:
|
case *model.Playlist:
|
||||||
return api.ds.Playlist(), "playlist"
|
return annotatedItem{api.ds.Playlist(), "playlist", e.Annotations}, true
|
||||||
}
|
}
|
||||||
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
// Unknown ids, inaccessible-library items and non-annotatable entities (radios) all read as absent.
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(w, "Not Found", http.StatusNotFound)
|
||||||
return nil, ""
|
return annotatedItem{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
|
// getUserItemData returns the caller's play/favorite/rating state for a single item. Jellify
|
||||||
|
|
@ -65,65 +70,135 @@ func (api *Router) getUserItemData(w http.ResponseWriter, r *http.Request) {
|
||||||
api.ok(w, r, data)
|
api.ok(w, r, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) setFavorite(w http.ResponseWriter, r *http.Request, starred bool) {
|
// userDataChange holds already-validated writes; a nil field leaves that annotation untouched.
|
||||||
|
type userDataChange struct {
|
||||||
|
rating *int // Navidrome stars; 0 clears
|
||||||
|
favorite *bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyUserData writes change for the itemId path param and answers with the item's resulting
|
||||||
|
// UserData, as Jellyfin does.
|
||||||
|
func (api *Router) applyUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
||||||
id, ok := itemIDParam(w, r, "itemId")
|
id, ok := itemIDParam(w, r, "itemId")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
repo, resource := api.resolveAnnotated(w, r, id)
|
item, ok := api.resolveAnnotated(w, r, id)
|
||||||
if repo == nil {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := repo.SetStar(r.Context(), starred, id); err != nil {
|
ctx := r.Context()
|
||||||
|
if change.rating != nil {
|
||||||
|
if err := item.repo.SetRating(ctx, *change.rating, id); err != nil {
|
||||||
api.internalError(w, r, err)
|
api.internalError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
item.ann.Rating = *change.rating
|
||||||
encodedID := dto.EncodeID(id)
|
}
|
||||||
api.ok(w, r, &dto.UserItemDataDto{IsFavorite: starred, Key: encodedID, ItemId: encodedID})
|
if change.favorite != nil {
|
||||||
|
if err := item.repo.SetStar(ctx, *change.favorite, id); err != nil {
|
||||||
|
api.internalError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item.ann.Starred = *change.favorite
|
||||||
|
}
|
||||||
|
if change.rating != nil || change.favorite != nil {
|
||||||
|
api.broker.SendMessage(ctx, (&events.RefreshResource{}).With(item.resource, id))
|
||||||
|
}
|
||||||
|
api.ok(w, r, dto.UserData(item.ann, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyCallerUserData is applyUserData for routes that may name a userId: only the caller's own
|
||||||
|
// annotations can be written, so naming another user is refused. Undecodable ids are ignored.
|
||||||
|
func (api *Router) applyCallerUserData(w http.ResponseWriter, r *http.Request, change userDataChange) {
|
||||||
|
raw := chi.URLParam(r, "userId")
|
||||||
|
if raw == "" {
|
||||||
|
raw = r.URL.Query().Get("userid")
|
||||||
|
}
|
||||||
|
if target, ok := dto.DecodeID(raw); ok {
|
||||||
|
if u, _ := request.UserFrom(r.Context()); target != u.ID {
|
||||||
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.applyUserData(w, r, change)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
api.applyUserData(w, r, userDataChange{favorite: new(true)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) markFavorite(w http.ResponseWriter, r *http.Request) { api.setFavorite(w, r, true) }
|
|
||||||
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
func (api *Router) unmarkFavorite(w http.ResponseWriter, r *http.Request) {
|
||||||
api.setFavorite(w, r, false)
|
api.applyUserData(w, r, userDataChange{favorite: new(false)})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) setItemRating(w http.ResponseWriter, r *http.Request, rating int) {
|
// setRating reads Jellyfin's likes flag, or the numeric rating extension older clients send. Unlike
|
||||||
id, ok := itemIDParam(w, r, "itemId")
|
// Jellyfin, a missing or malformed value is a 400 rather than a clear: clearing is DELETE's job.
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
repo, resource := api.resolveAnnotated(w, r, id)
|
|
||||||
if repo == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := repo.SetRating(r.Context(), rating, id); err != nil {
|
|
||||||
api.internalError(w, r, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
api.broker.SendMessage(r.Context(), (&events.RefreshResource{}).With(resource, id))
|
|
||||||
encodedID := dto.EncodeID(id)
|
|
||||||
d := &dto.UserItemDataDto{Key: encodedID, ItemId: encodedID}
|
|
||||||
if rating > 0 {
|
|
||||||
jfRating := float64(rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10, mirrors dto.UserData
|
|
||||||
d.Rating = &jfRating
|
|
||||||
}
|
|
||||||
api.ok(w, r, d)
|
|
||||||
}
|
|
||||||
|
|
||||||
// setRating maps Jellyfin's 0-10 rating (a nullable double, so fractional values are valid) to
|
|
||||||
// Navidrome's 0-5 stars. A nonzero rating floors at one star: rounding to 0 would clear it, since
|
|
||||||
// SetRating(0) is the delete path.
|
|
||||||
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
func (api *Router) setRating(w http.ResponseWriter, r *http.Request) {
|
||||||
jfRating := req.Params(r).Float64Or("rating", 0)
|
q := r.URL.Query()
|
||||||
jfRating = min(max(jfRating, 0), 10) // clamp: a client sending e.g. Rating=100 must not write an out-of-domain rating
|
var stars int
|
||||||
rating := int(math.Round(jfRating / 2))
|
switch {
|
||||||
if jfRating > 0 {
|
case q.Has("likes"):
|
||||||
rating = max(rating, 1)
|
likes, err := strconv.ParseBool(q.Get("likes"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "likes must be true or false", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
api.setItemRating(w, r, rating)
|
stars = dto.StarsFromLikes(likes)
|
||||||
|
case q.Has("rating"):
|
||||||
|
rating, err := strconv.ParseFloat(q.Get("rating"), 64)
|
||||||
|
s, ok := dto.StarsFromRating(rating)
|
||||||
|
if err != nil || !ok {
|
||||||
|
http.Error(w, "rating must be a number from 0 to 10", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stars = s
|
||||||
|
default:
|
||||||
|
http.Error(w, "likes is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.applyCallerUserData(w, r, userDataChange{rating: &stars})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
func (api *Router) removeRating(w http.ResponseWriter, r *http.Request) {
|
||||||
api.setItemRating(w, r, 0)
|
api.applyCallerUserData(w, r, userDataChange{rating: new(0)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateUserItemData writes Rating, Likes and IsFavorite; omitted or null fields are left untouched.
|
||||||
|
// Play state is ignored because it only changes through playback reporting.
|
||||||
|
func (api *Router) updateUserItemData(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, ok := decodeUserDataBody(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change := userDataChange{favorite: body.IsFavorite}
|
||||||
|
// Jellyfin applies Likes before Rating, so Rating wins when a body carries both.
|
||||||
|
switch {
|
||||||
|
case body.Rating != nil:
|
||||||
|
stars, ok := dto.StarsFromRating(*body.Rating)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "Rating must be from 0 to 10", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
change.rating = &stars
|
||||||
|
case body.Likes != nil:
|
||||||
|
change.rating = new(dto.StarsFromLikes(*body.Likes))
|
||||||
|
}
|
||||||
|
api.applyCallerUserData(w, r, change)
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxUserDataBody = 64 << 10
|
||||||
|
|
||||||
|
// decodeUserDataBody accepts exactly one JSON object; Decode alone would ignore anything after it.
|
||||||
|
func decodeUserDataBody(w http.ResponseWriter, r *http.Request) (*dto.UpdateUserItemDataDto, bool) {
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxUserDataBody))
|
||||||
|
var body *dto.UpdateUserItemDataDto
|
||||||
|
err := dec.Decode(&body)
|
||||||
|
if err == nil && body != nil {
|
||||||
|
if _, err = dec.Token(); errors.Is(err, io.EOF) {
|
||||||
|
return body, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
http.Error(w, "Bad Request", http.StatusBadRequest)
|
||||||
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
"github.com/navidrome/navidrome/model/request"
|
"github.com/navidrome/navidrome/model/request"
|
||||||
|
|
@ -21,7 +23,7 @@ var _ = Describe("Annotations", func() {
|
||||||
var broker *fakeEventBroker
|
var broker *fakeEventBroker
|
||||||
// alice has access to library 1 only.
|
// alice has access to library 1 only.
|
||||||
ctxUser := func() context.Context {
|
ctxUser := func() context.Context {
|
||||||
return request.WithUser(context.Background(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
return request.WithUser(GinkgoT().Context(), model.User{ID: testID("u1"), UserName: "alice", Libraries: model.Libraries{{ID: 1, Name: "Music"}}})
|
||||||
}
|
}
|
||||||
|
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
|
|
@ -30,6 +32,16 @@ var _ = Describe("Annotations", func() {
|
||||||
api = &Router{ds: ds, broker: broker}
|
api = &Router{ds: ds, broker: broker}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
songWith := func(ann model.Annotations) *tests.MockMediaFileRepo {
|
||||||
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||||
|
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1, Annotations: ann}})
|
||||||
|
return mfRepo
|
||||||
|
}
|
||||||
|
songReq := func(method, target, body string, params ...string) *http.Request {
|
||||||
|
r := httptest.NewRequest(method, target, strings.NewReader(body)).WithContext(ctxUser())
|
||||||
|
return withChiURLParam(r, append([]string{"itemId", dto.EncodeID(testID("s1"))}, params...)...)
|
||||||
|
}
|
||||||
|
|
||||||
Describe("markFavorite / unmarkFavorite", func() {
|
Describe("markFavorite / unmarkFavorite", func() {
|
||||||
It("stars a song and returns IsFavorite=true", func() {
|
It("stars a song and returns IsFavorite=true", func() {
|
||||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
||||||
|
|
@ -268,41 +280,275 @@ var _ = Describe("Annotations", func() {
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clamps a Rating above 10 to Navidrome's max (5)", func() {
|
|
||||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=100", nil).WithContext(ctxUser())
|
|
||||||
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
||||||
invoke(api.setRating, w, r)
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("clamps a negative Rating to Navidrome's min (0)", func() {
|
|
||||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=-5", nil).WithContext(ctxUser())
|
|
||||||
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
||||||
invoke(api.setRating, w, r)
|
|
||||||
Expect(w.Code).To(Equal(http.StatusOK))
|
|
||||||
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
|
||||||
})
|
|
||||||
|
|
||||||
It("emits a refreshResource event when rating a song", func() {
|
It("emits a refreshResource event when rating a song", func() {
|
||||||
mfRepo := ds.MediaFile().(*tests.MockMediaFileRepo)
|
songWith(model.Annotations{})
|
||||||
mfRepo.SetData(model.MediaFiles{{ID: testID("s1"), Title: "Song", LibraryID: 1}})
|
invoke(api.setRating, httptest.NewRecorder(), songReq("POST", "/Users/u1/Items/s1/Rating?Rating=8", ""))
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r := httptest.NewRequest("POST", "/Users/u1/Items/s1/Rating?Rating=8", nil).WithContext(ctxUser())
|
|
||||||
r = withChiURLParam(r, "itemId", dto.EncodeID(testID("s1")))
|
|
||||||
invoke(api.setRating, w, r)
|
|
||||||
Expect(broker.Events).To(HaveLen(1))
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
Expect(broker.Events[0].Data(broker.Events[0])).To(Equal(`{"song":["` + testID("s1") + `"]}`))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Context("Jellyfin likes semantics", func() {
|
||||||
|
It("stores likes=true as 5 stars and reports Rating 10 / Likes true", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(*d.Rating).To(Equal(10.0))
|
||||||
|
Expect(*d.Likes).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("stores likes=false as 1 star (Jellyfin's dislike is Rating 1), not a clear", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?Likes=False", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(*d.Rating).To(Equal(2.0))
|
||||||
|
Expect(*d.Likes).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("prefers likes over the numeric rating extension when both are sent", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=false&rating=10", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("still clears on an explicit rating=0 from numeric clients", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?rating=0", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(0))
|
||||||
|
Expect(parseUserData(w).Rating).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("keeps the favorite and play state and reports them in the response", func() {
|
||||||
|
playDate := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 3, PlayDate: &playDate})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating?likes=true", ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.Starred).To(BeTrue())
|
||||||
|
Expect(song.PlayCount).To(Equal(int64(3)))
|
||||||
|
Expect(song.PlayDate).To(Equal(&playDate))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(d.IsFavorite).To(BeTrue())
|
||||||
|
Expect(d.PlayCount).To(Equal(3))
|
||||||
|
Expect(d.Played).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("rejects a missing or malformed value without touching the stored rating",
|
||||||
|
func(query string) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/UserItems/s1/Rating"+query, ""))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
},
|
||||||
|
Entry("no params (Jellyfin would clear)", ""),
|
||||||
|
Entry("unrelated params only", "?userId=x&foo=1"),
|
||||||
|
Entry("empty likes", "?likes="),
|
||||||
|
Entry("non-boolean likes", "?likes=maybe"),
|
||||||
|
Entry("non-boolean likes even with a valid rating", "?likes=maybe&rating=8"),
|
||||||
|
Entry("empty rating", "?rating="),
|
||||||
|
Entry("non-numeric rating", "?rating=abc"),
|
||||||
|
Entry("NaN rating", "?rating=NaN"),
|
||||||
|
Entry("infinite rating", "?rating=Inf"),
|
||||||
|
Entry("rating above 10", "?Rating=100"),
|
||||||
|
Entry("negative rating", "?Rating=-5"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("authorization", func() {
|
||||||
|
DescribeTable("refuses a userId naming another user and leaves the rating",
|
||||||
|
func(method, target string, params ...string) {
|
||||||
|
handler := api.setRating
|
||||||
|
if method == "DELETE" {
|
||||||
|
handler = api.removeRating
|
||||||
|
}
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(handler, w, songReq(method, target, "", params...))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
},
|
||||||
|
Entry("path userId", "POST", "/Users/u2/Items/s1/Rating?likes=true", "userId", dto.EncodeID(testID("u2"))),
|
||||||
|
Entry("query userId on the /UserItems form", "POST", "/UserItems/s1/Rating?likes=true&userId="+dto.EncodeID(testID("u2"))),
|
||||||
|
Entry("clearing", "DELETE", "/Users/u2/Items/s1/Rating", "userId", dto.EncodeID(testID("u2"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("accepts the caller's own userId", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.setRating, w, songReq("POST", "/Users/u1/Items/s1/Rating?likes=true", "", "userId", dto.EncodeID(testID("u1"))))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(5))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("updateUserItemData", func() {
|
||||||
|
post := func(body string, params ...string) *httptest.ResponseRecorder {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
invoke(api.updateUserItemData, w, songReq("POST", "/UserItems/s1/UserData", body, params...))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
It("maps a 0-10 Rating to stars and answers with the full UserData", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Starred: true, PlayCount: 2})
|
||||||
|
w := post(`{"Rating":8}`)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
d := parseUserData(w)
|
||||||
|
Expect(*d.Rating).To(Equal(8.0))
|
||||||
|
Expect(*d.Likes).To(BeTrue())
|
||||||
|
Expect(d.IsFavorite).To(BeTrue())
|
||||||
|
Expect(d.PlayCount).To(Equal(2))
|
||||||
|
Expect(d.ItemId).To(Equal(dto.EncodeID(testID("s1"))))
|
||||||
|
Expect(broker.Events).To(HaveLen(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("stores the personal rating",
|
||||||
|
func(body string, before, expected int) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: before})
|
||||||
|
Expect(post(body).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("Likes true", `{"Likes":true}`, 0, 5),
|
||||||
|
Entry("Likes false (Rating 1)", `{"Likes":false}`, 4, 1),
|
||||||
|
Entry("Rating wins over Likes, as Jellyfin applies Likes first", `{"Likes":true,"Rating":2}`, 0, 1),
|
||||||
|
Entry("fractional Rating rounds to the nearest star", `{"Rating":7.5}`, 0, 4),
|
||||||
|
Entry("small nonzero Rating floors at one star", `{"Rating":0.4}`, 0, 1),
|
||||||
|
Entry("Rating 0 clears", `{"Rating":0}`, 4, 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
DescribeTable("leaves the rating untouched when Rating and Likes are omitted or null",
|
||||||
|
func(body string, wantEvents int) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := post(body)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(*parseUserData(w).Rating).To(Equal(8.0))
|
||||||
|
Expect(broker.Events).To(HaveLen(wantEvents))
|
||||||
|
},
|
||||||
|
Entry("empty object writes nothing", `{}`, 0),
|
||||||
|
Entry("explicit nulls write nothing", `{"Rating":null,"Likes":null,"IsFavorite":null}`, 0),
|
||||||
|
Entry("favorite only", `{"IsFavorite":true}`, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("sets and unsets the favorite without touching the rating", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
Expect(post(`{"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeTrue())
|
||||||
|
Expect(post(`{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("ignores play-state fields, which only change through playback reporting", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{PlayCount: 2})
|
||||||
|
w := post(`{"Played":true,"PlayCount":99,"LastPlayedDate":"2026-01-01T00:00:00Z","PlaybackPositionTicks":5}`)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusOK))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.PlayCount).To(Equal(int64(2)))
|
||||||
|
Expect(song.PlayDate).To(BeNil())
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves state unchanged when a client echoes back the UserData it read", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 3, Starred: true, PlayCount: 1})
|
||||||
|
echo, err := json.Marshal(dto.UserData(mfRepo.Data[testID("s1")].Annotations, testID("s1")))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(post(string(echo)).Code).To(Equal(http.StatusOK))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.Rating).To(Equal(3))
|
||||||
|
Expect(song.Starred).To(BeTrue())
|
||||||
|
Expect(song.PlayCount).To(Equal(int64(1)))
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("rejects an invalid body without writing anything",
|
||||||
|
func(body string) {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
Expect(post(body).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
song := mfRepo.Data[testID("s1")]
|
||||||
|
Expect(song.Rating).To(Equal(4))
|
||||||
|
Expect(song.Starred).To(BeFalse())
|
||||||
|
Expect(broker.Events).To(BeEmpty())
|
||||||
|
},
|
||||||
|
Entry("empty body", ""),
|
||||||
|
Entry("malformed JSON", `{"Rating":`),
|
||||||
|
Entry("Rating above 10, even with a valid IsFavorite", `{"IsFavorite":true,"Rating":10.5}`),
|
||||||
|
Entry("negative Rating", `{"IsFavorite":true,"Rating":-1}`),
|
||||||
|
Entry("Rating as a string", `{"Rating":"8"}`),
|
||||||
|
Entry("Likes as a string", `{"Likes":"yes"}`),
|
||||||
|
Entry("IsFavorite as a number", `{"IsFavorite":1}`),
|
||||||
|
Entry("JSON null", `null`),
|
||||||
|
Entry("JSON array", `[]`),
|
||||||
|
Entry("trailing garbage after the object", `{"Rating":8} x`),
|
||||||
|
Entry("a second JSON value after the object", `{"Rating":8}{"Rating":2}`),
|
||||||
|
Entry("a second object after whitespace", "{\"Rating\":8}\n{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("accepts trailing whitespace after the object", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{})
|
||||||
|
Expect(post("{\"Rating\":8}\r\n\t ").Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects a body larger than the limit", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
big := `{"Rating":8,"Pad":"` + strings.Repeat("x", 70<<10) + `"}`
|
||||||
|
Expect(post(big).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
})
|
||||||
|
|
||||||
|
Context("authorization", func() {
|
||||||
|
It("refuses a userId naming another user without writing", func() {
|
||||||
|
mfRepo := songWith(model.Annotations{Rating: 4})
|
||||||
|
w := post(`{"Rating":10,"IsFavorite":true}`, "userId", dto.EncodeID(testID("u2")))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Rating).To(Equal(4))
|
||||||
|
Expect(mfRepo.Data[testID("s1")].Starred).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("accepts the caller's own userId", func() {
|
||||||
|
songWith(model.Annotations{})
|
||||||
|
Expect(post(`{"Rating":10}`, "userId", dto.EncodeID(testID("u1"))).Code).To(Equal(http.StatusOK))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 and writes nothing for an album in a library the user can't access", func() {
|
||||||
|
albumRepo := ds.Album().(*tests.MockAlbumRepo)
|
||||||
|
albumRepo.SetData(model.Albums{{ID: testID("a1"), Name: "One", LibraryID: 2}})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/UserItems/a1/UserData", strings.NewReader(`{"Rating":10,"IsFavorite":true}`)).WithContext(ctxUser())
|
||||||
|
invoke(api.updateUserItemData, w, withChiURLParam(r, "itemId", dto.EncodeID(testID("a1"))))
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
Expect(albumRepo.Data[testID("a1")].Rating).To(Equal(0))
|
||||||
|
Expect(albumRepo.Data[testID("a1")].Starred).To(BeFalse())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("returns 404 for an unknown item", func() {
|
||||||
|
w := post(`{"Rating":10}`)
|
||||||
|
Expect(w.Code).To(Equal(http.StatusNotFound))
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
func parseUserData(w *httptest.ResponseRecorder) dto.UserItemDataDto {
|
||||||
|
var d dto.UserItemDataDto
|
||||||
|
Expect(json.Unmarshal(w.Body.Bytes(), &d)).To(Succeed())
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
type fakeEventBroker struct {
|
type fakeEventBroker struct {
|
||||||
http.Handler
|
http.Handler
|
||||||
Events []events.Event
|
Events []events.Event
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,8 @@ func (api *Router) routes() http.Handler {
|
||||||
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
|
r.Delete("/userfavoriteitems/{itemId}", api.unmarkFavorite)
|
||||||
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
|
r.Post("/users/{userId}/favoriteitems/{itemId}", api.markFavorite)
|
||||||
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
|
r.Delete("/users/{userId}/favoriteitems/{itemId}", api.unmarkFavorite)
|
||||||
|
r.Post("/useritems/{itemId}/rating", api.setRating)
|
||||||
|
r.Delete("/useritems/{itemId}/rating", api.removeRating)
|
||||||
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
|
r.Post("/users/{userId}/items/{itemId}/rating", api.setRating)
|
||||||
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
|
r.Delete("/users/{userId}/items/{itemId}/rating", api.removeRating)
|
||||||
|
|
||||||
|
|
@ -153,6 +155,8 @@ func (api *Router) routes() http.Handler {
|
||||||
// /Users/{userId}/Items is the legacy spelling.
|
// /Users/{userId}/Items is the legacy spelling.
|
||||||
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
r.Get("/useritems/{itemId}/userdata", api.getUserItemData)
|
||||||
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
r.Get("/users/{userId}/items/{itemId}/userdata", api.getUserItemData)
|
||||||
|
r.Post("/useritems/{itemId}/userdata", api.updateUserItemData)
|
||||||
|
r.Post("/users/{userId}/items/{itemId}/userdata", api.updateUserItemData)
|
||||||
|
|
||||||
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
r.Get("/artists/{itemId}/similar", api.getSimilarArtists)
|
||||||
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
r.Get("/items/{itemId}/similar", api.getSimilarItems)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,14 @@ type UserItemDataDto struct {
|
||||||
Key string `json:"Key"`
|
Key string `json:"Key"`
|
||||||
ItemId string `json:"ItemId,omitempty"`
|
ItemId string `json:"ItemId,omitempty"`
|
||||||
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
LastPlayedDate *string `json:"LastPlayedDate,omitempty"`
|
||||||
|
Likes *bool `json:"Likes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserItemDataDto is the POST .../UserData body; other Jellyfin fields are accepted and ignored.
|
||||||
|
type UpdateUserItemDataDto struct {
|
||||||
|
Rating *float64 `json:"Rating"`
|
||||||
|
Likes *bool `json:"Likes"`
|
||||||
|
IsFavorite *bool `json:"IsFavorite"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseItemDto struct {
|
type BaseItemDto struct {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package dto
|
||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/conf"
|
"github.com/navidrome/navidrome/conf"
|
||||||
|
|
@ -121,6 +122,30 @@ func MediaSourceFromMediaFile(mf model.MediaFile) MediaSourceInfo {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// minLikeRating is Jellyfin's UserItemData.MinLikeValue: a rating at or above it reads as Likes.
|
||||||
|
const minLikeRating = 6.5
|
||||||
|
|
||||||
|
// StarsFromRating maps Jellyfin's 0-10 rating to 0-5 stars, rejecting out-of-range values and NaN.
|
||||||
|
// A nonzero rating floors at one star, since 0 stars means "not rated".
|
||||||
|
func StarsFromRating(rating float64) (int, bool) {
|
||||||
|
if !(rating >= 0 && rating <= 10) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
stars := int(math.Round(rating / 2))
|
||||||
|
if rating > 0 {
|
||||||
|
stars = max(stars, 1)
|
||||||
|
}
|
||||||
|
return stars, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// StarsFromLikes mirrors Jellyfin, which stores likes as Rating 10 and dislikes as Rating 1.
|
||||||
|
func StarsFromLikes(likes bool) int {
|
||||||
|
if likes {
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
||||||
// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
|
// Callers pass the raw model id; encode here so Key/ItemId match the encoded Id on the BaseItemDto.
|
||||||
encodedID := EncodeID(itemID)
|
encodedID := EncodeID(itemID)
|
||||||
|
|
@ -134,6 +159,7 @@ func UserData(a model.Annotations, itemID string) *UserItemDataDto {
|
||||||
if a.Rating > 0 {
|
if a.Rating > 0 {
|
||||||
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
|
r := float64(a.Rating) * 2 // Navidrome 0-5 -> Jellyfin 0-10
|
||||||
d.Rating = &r
|
d.Rating = &r
|
||||||
|
d.Likes = new(r >= minLikeRating)
|
||||||
}
|
}
|
||||||
if s := JellyfinDate(a.PlayDate); s != "" {
|
if s := JellyfinDate(a.PlayDate); s != "" {
|
||||||
d.LastPlayedDate = &s
|
d.LastPlayedDate = &s
|
||||||
|
|
|
||||||
|
|
@ -478,6 +478,30 @@ var _ = Describe("mappers", func() {
|
||||||
Expect(item.ImageBlurHashes).To(BeNil())
|
Expect(item.ImageBlurHashes).To(BeNil())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
DescribeTable("UserData derives Likes from the rating like Jellyfin (rating >= 6.5)",
|
||||||
|
func(stars int, expected *bool) {
|
||||||
|
d := UserData(model.Annotations{Rating: stars}, testID("s1"))
|
||||||
|
Expect(d.Likes).To(Equal(expected))
|
||||||
|
},
|
||||||
|
Entry("unrated has no Likes", 0, nil),
|
||||||
|
Entry("1 star (Jellyfin 2) dislikes", 1, new(false)),
|
||||||
|
Entry("3 stars (Jellyfin 6) dislikes", 3, new(false)),
|
||||||
|
Entry("4 stars (Jellyfin 8) likes", 4, new(true)),
|
||||||
|
Entry("5 stars (Jellyfin 10) likes", 5, new(true)),
|
||||||
|
)
|
||||||
|
|
||||||
|
It("emits Rating as a whole number and omits Likes when unrated", func() {
|
||||||
|
rated, err := json.Marshal(UserData(model.Annotations{Rating: 4}, testID("s1")))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(rated)).To(ContainSubstring(`"Rating":8,`))
|
||||||
|
Expect(string(rated)).To(ContainSubstring(`"Likes":true`))
|
||||||
|
|
||||||
|
unrated, err := json.Marshal(UserData(model.Annotations{}, testID("s1")))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(string(unrated)).ToNot(ContainSubstring(`"Rating"`))
|
||||||
|
Expect(string(unrated)).ToNot(ContainSubstring(`"Likes"`))
|
||||||
|
})
|
||||||
|
|
||||||
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
|
It("emits DateCreated and SortName on playlists, like albums and artists", func() {
|
||||||
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
|
p := model.Playlist{ID: testID("pl-1"), Name: "Chill", CreatedAt: time.Date(2026, 7, 1, 12, 0, 0, 0, time.UTC)}
|
||||||
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
|
Expect(PlaylistToBaseItem(p, nil).DateCreated).To(Equal("2026-07-01T12:00:00.0000000Z"))
|
||||||
|
|
|
||||||
|
|
@ -130,13 +130,78 @@ var _ = Describe("Annotations", func() {
|
||||||
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("clamps an out-of-range rating to the valid domain", func() {
|
It("rejects an out-of-range rating and keeps the stored one", func() {
|
||||||
id := albumID("Help!")
|
id := albumID("Help!")
|
||||||
|
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=8", "").Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", "").Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(*itemUserData(id).Rating).To(Equal(float64(8)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("stores Jellyfin likes on the current /UserItems route instead of clearing", func() {
|
||||||
|
id := songID("So What")
|
||||||
|
var liked dto.UserItemDataDto
|
||||||
|
parseInto(post("/UserItems/"+enc(id)+"/Rating?likes=true", ""), &liked)
|
||||||
|
Expect(*liked.Rating).To(Equal(float64(10)))
|
||||||
|
Expect(*liked.Likes).To(BeTrue())
|
||||||
|
|
||||||
|
var disliked dto.UserItemDataDto
|
||||||
|
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?likes=false", ""), &disliked)
|
||||||
|
Expect(*disliked.Rating).To(Equal(float64(2)))
|
||||||
|
Expect(*disliked.Likes).To(BeFalse())
|
||||||
|
Expect(*itemUserData(id).Likes).To(BeFalse())
|
||||||
|
|
||||||
|
Expect(del("/UserItems/" + enc(id) + "/Rating").Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(itemUserData(id).Rating).To(BeNil())
|
||||||
|
Expect(itemUserData(id).Likes).To(BeNil())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("does not clear a rating when the request has no value", func() {
|
||||||
|
id := songID("So What")
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/Rating", "").Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/Rating?likes=sure", "").Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(*itemUserData(id).Rating).To(Equal(float64(10)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("POST /UserItems/{id}/UserData", func() {
|
||||||
|
It("sets Rating, Likes and IsFavorite on both route spellings", func() {
|
||||||
|
id := songID("Come Together")
|
||||||
var data dto.UserItemDataDto
|
var data dto.UserItemDataDto
|
||||||
parseInto(post("/Users/admin-1/Items/"+enc(id)+"/Rating?Rating=100", ""), &data)
|
parseInto(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":6}`), &data)
|
||||||
// 100 clamps to 10 (Jellyfin) -> 5 (Navidrome) -> 10 back out.
|
Expect(*data.Rating).To(Equal(float64(6)))
|
||||||
Expect(data.Rating).ToNot(BeNil())
|
Expect(*data.Likes).To(BeFalse())
|
||||||
Expect(*data.Rating).To(Equal(float64(10)))
|
|
||||||
|
parseInto(post("/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Likes":true,"IsFavorite":true}`), &data)
|
||||||
|
stored := itemUserData(id)
|
||||||
|
Expect(*stored.Rating).To(Equal(float64(10)))
|
||||||
|
Expect(*stored.Likes).To(BeTrue())
|
||||||
|
Expect(stored.IsFavorite).To(BeTrue())
|
||||||
|
})
|
||||||
|
|
||||||
|
It("leaves omitted fields and play state untouched", func() {
|
||||||
|
id := songID("Come Together")
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8,"IsFavorite":true}`).Code).To(Equal(http.StatusOK))
|
||||||
|
before := itemUserData(id)
|
||||||
|
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Played":true,"PlayCount":42,"LastPlayedDate":"2026-01-01T00:00:00Z"}`).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"IsFavorite":false}`).Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
after := itemUserData(id)
|
||||||
|
Expect(*after.Rating).To(Equal(float64(8)))
|
||||||
|
Expect(after.IsFavorite).To(BeFalse())
|
||||||
|
Expect(after.PlayCount).To(Equal(before.PlayCount))
|
||||||
|
Expect(after.Played).To(Equal(before.Played))
|
||||||
|
Expect(after.LastPlayedDate).To(Equal(before.LastPlayedDate))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects invalid bodies without writing", func() {
|
||||||
|
id := songID("Come Together")
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":11,"IsFavorite":true}`).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":8} {"Rating":2}`).Code).To(Equal(http.StatusBadRequest))
|
||||||
|
stored := itemUserData(id)
|
||||||
|
Expect(stored.Rating).To(BeNil())
|
||||||
|
Expect(stored.IsFavorite).To(BeFalse())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/navidrome/navidrome/model"
|
"github.com/navidrome/navidrome/model"
|
||||||
|
"github.com/navidrome/navidrome/server/jellyfin/dto"
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
@ -28,6 +29,36 @@ var _ = Describe("Multi-user access control", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("personal ratings", func() {
|
||||||
|
userData := func(u model.User, id string) *dto.UserItemDataDto {
|
||||||
|
var data dto.UserItemDataDto
|
||||||
|
parseInto(getAs(u, "/UserItems/"+enc(id)+"/UserData"), &data)
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
|
It("keeps each user's rating separate", func() {
|
||||||
|
id := songID("So What")
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusOK))
|
||||||
|
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/Rating?likes=false", "").Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
Expect(*userData(adminUser, id).Rating).To(Equal(float64(10)))
|
||||||
|
Expect(*userData(regularUser, id).Rating).To(Equal(float64(2)))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("refuses writes that name another user, even for an admin, and changes nothing", func() {
|
||||||
|
id := songID("So What")
|
||||||
|
Expect(postAs(regularUser, "/UserItems/"+enc(id)+"/UserData", `{"Rating":4}`).Code).To(Equal(http.StatusOK))
|
||||||
|
|
||||||
|
Expect(postAs(regularUser, "/Users/"+enc(adminUser.ID)+"/Items/"+enc(id)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(post("/Users/"+enc(regularUser.ID)+"/Items/"+enc(id)+"/Rating?likes=true", "").Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(del("/Users/" + enc(regularUser.ID) + "/Items/" + enc(id) + "/Rating").Code).To(Equal(http.StatusForbidden))
|
||||||
|
Expect(post("/UserItems/"+enc(id)+"/UserData?userId="+enc(regularUser.ID), `{"Rating":10}`).Code).To(Equal(http.StatusForbidden))
|
||||||
|
|
||||||
|
Expect(userData(adminUser, id).Rating).To(BeNil())
|
||||||
|
Expect(*userData(regularUser, id).Rating).To(Equal(float64(4)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("private playlists", func() {
|
Describe("private playlists", func() {
|
||||||
It("does not expose another user's private playlist", func() {
|
It("does not expose another user's private playlist", func() {
|
||||||
adminPl := createPlaylist("Admin Private", nil)
|
adminPl := createPlaylist("Admin Private", nil)
|
||||||
|
|
@ -53,6 +84,7 @@ var _ = Describe("Multi-user access control", func() {
|
||||||
adminPl := createPlaylist("Admin Private", nil)
|
adminPl := createPlaylist("Admin Private", nil)
|
||||||
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
Expect(postAs(regularUser, "/Users/user-1/FavoriteItems/"+enc(adminPl), "").Code).To(Equal(http.StatusNotFound))
|
||||||
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
Expect(postAs(regularUser, "/Users/user-1/Items/"+enc(adminPl)+"/Rating?Rating=10", "").Code).To(Equal(http.StatusNotFound))
|
||||||
|
Expect(postAs(regularUser, "/UserItems/"+enc(adminPl)+"/UserData", `{"Rating":10}`).Code).To(Equal(http.StatusNotFound))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("lets each user manage their own playlist", func() {
|
It("lets each user manage their own playlist", func() {
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ import (
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
)
|
)
|
||||||
|
|
||||||
// withChiURLParam simulates chi's routing having captured a path parameter, since these
|
// withChiURLParam simulates chi's routing having captured path parameters (key, value pairs), since
|
||||||
// tests call handlers directly instead of going through the full router.
|
// these tests call handlers directly instead of going through the full router.
|
||||||
func withChiURLParam(r *http.Request, key, value string) *http.Request {
|
func withChiURLParam(r *http.Request, kv ...string) *http.Request {
|
||||||
rctx := chi.NewRouteContext()
|
rctx := chi.NewRouteContext()
|
||||||
rctx.URLParams.Add(key, value)
|
for i := 0; i+1 < len(kv); i += 2 {
|
||||||
|
rctx.URLParams.Add(kv[i], kv[i+1])
|
||||||
|
}
|
||||||
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,12 @@ func doInspect(ctx context.Context, ds model.DataStore, id string) (*core.Inspec
|
||||||
return nil, model.ErrNotFound
|
return nil, model.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return core.Inspect(file.AbsolutePath(), file.LibraryID, file.FolderID)
|
lib, err := ds.Library().Get(ctx, file.LibraryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return core.Inspect(file.AbsolutePath(), *lib, file.FolderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func inspect(ds model.DataStore) http.HandlerFunc {
|
func inspect(ds model.DataStore) http.HandlerFunc {
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,8 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{
|
streamReq := pub.decider.ResolveRequest(ctx, mf, info.format, info.bitrate, 0)
|
||||||
Format: info.format, BitRate: info.bitrate,
|
stream, err := pub.streamer.NewStream(ctx, mf, streamReq)
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, streampkg.ErrTooManyTranscodes) {
|
if errors.Is(err, streampkg.ErrTooManyTranscodes) {
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds))
|
w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds))
|
||||||
|
|
|
||||||
|
|
@ -116,11 +116,11 @@ var _ = Describe("handleStream", func() {
|
||||||
BeforeEach(func() {
|
BeforeEach(func() {
|
||||||
ctx = GinkgoT().Context()
|
ctx = GinkgoT().Context()
|
||||||
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
auth.PublicTokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil)
|
||||||
ds = &tests.MockDataStore{}
|
ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}}
|
||||||
shareRepo = &tests.MockShareRepo{}
|
shareRepo = &tests.MockShareRepo{}
|
||||||
ds.MockedShare = shareRepo
|
ds.MockedShare = shareRepo
|
||||||
streamer = &mockStreamer{}
|
streamer = &mockStreamer{}
|
||||||
pub = &Router{ds: ds, streamer: streamer}
|
pub = &Router{ds: ds, streamer: streamer, decider: stream.NewTranscodeDecider(ds, tests.NewMockFFmpeg(""))}
|
||||||
})
|
})
|
||||||
|
|
||||||
makeRequest := func(token string) *httptest.ResponseRecorder {
|
makeRequest := func(token string) *httptest.ResponseRecorder {
|
||||||
|
|
@ -152,8 +152,17 @@ var _ = Describe("handleStream", func() {
|
||||||
makeRequest(token)
|
makeRequest(token)
|
||||||
|
|
||||||
Expect(streamer.called).To(BeTrue())
|
Expect(streamer.called).To(BeTrue())
|
||||||
Expect(streamer.req.Format).To(Equal("mp3"))
|
})
|
||||||
Expect(streamer.req.BitRate).To(Equal(192))
|
|
||||||
|
It("resolves the full stream request like the Subsonic endpoint, so transcodes share the cache", func() {
|
||||||
|
mf := model.MediaFile{ID: "mf-123", Suffix: "flac", BitRate: 1500, SampleRate: 44100, BitDepth: new(24), Channels: 2}
|
||||||
|
shareOwnedBy(model.User{ID: "owner1", UserName: "owner1", IsAdmin: true}, mf)
|
||||||
|
|
||||||
|
claims := auth.Claims{ID: "mf-123", Format: "opus", BitRate: 128, ShareID: "share123"}
|
||||||
|
token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims)
|
||||||
|
makeRequest(token)
|
||||||
|
|
||||||
|
Expect(streamer.req).To(Equal(stream.Request{Format: "opus", BitRate: 128, SampleRate: 48000, Channels: 2}))
|
||||||
})
|
})
|
||||||
|
|
||||||
It("returns 404 when the track is outside the share owner's libraries", func() {
|
It("returns 404 when the track is outside the share owner's libraries", func() {
|
||||||
|
|
|
||||||
|
|
@ -21,14 +21,15 @@ type Router struct {
|
||||||
http.Handler
|
http.Handler
|
||||||
artwork artwork.Artwork
|
artwork artwork.Artwork
|
||||||
streamer stream.MediaStreamer
|
streamer stream.MediaStreamer
|
||||||
|
decider stream.TranscodeDecider
|
||||||
archiver core.Archiver
|
archiver core.Archiver
|
||||||
share core.Share
|
share core.Share
|
||||||
assetsHandler http.Handler
|
assetsHandler http.Handler
|
||||||
ds model.DataStore
|
ds model.DataStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, share core.Share, archiver core.Archiver) *Router {
|
func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, decider stream.TranscodeDecider, share core.Share, archiver core.Archiver) *Router {
|
||||||
p := &Router{ds: ds, artwork: artwork, streamer: streamer, share: share, archiver: archiver}
|
p := &Router{ds: ds, artwork: artwork, streamer: streamer, decider: decider, share: share, archiver: archiver}
|
||||||
shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic)
|
shareRoot := path.Join(conf.Server.BasePath, consts.URLPathPublic)
|
||||||
p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets())))
|
p.assetsHandler = http.StripPrefix(shareRoot, http.FileServer(http.FS(ui.BuildAssets())))
|
||||||
p.Handler = p.routes()
|
p.Handler = p.routes()
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl
|
||||||
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
"uiSearchDebounceMs": conf.Server.UISearchDebounceMs,
|
||||||
"uiCoverArtSize": conf.Server.UICoverArtSize,
|
"uiCoverArtSize": conf.Server.UICoverArtSize,
|
||||||
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
"enableCoverAnimation": conf.Server.EnableCoverAnimation,
|
||||||
|
"pidAlbum": conf.Server.PID.Album,
|
||||||
|
"pidTrack": conf.Server.PID.Track,
|
||||||
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
"enableNowPlaying": conf.Server.EnableNowPlaying,
|
||||||
"playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(),
|
"playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(),
|
||||||
"gaTrackingId": conf.Server.GATrackingID,
|
"gaTrackingId": conf.Server.GATrackingID,
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ var _ = Describe("serveIndex", func() {
|
||||||
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
Entry("uiSearchDebounceMs", func() { conf.Server.UISearchDebounceMs = 500 }, "uiSearchDebounceMs", float64(500)),
|
||||||
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
|
Entry("uiCoverArtSize", func() { conf.Server.UICoverArtSize = 300 }, "uiCoverArtSize", float64(300)),
|
||||||
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
Entry("enableCoverAnimation", func() { conf.Server.EnableCoverAnimation = true }, "enableCoverAnimation", true),
|
||||||
|
Entry("pidAlbum", func() { conf.Server.PID.Album = "folder" }, "pidAlbum", "folder"),
|
||||||
|
Entry("pidTrack", func() { conf.Server.PID.Track = "title" }, "pidTrack", "title"),
|
||||||
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
Entry("enableNowPlaying", func() { conf.Server.EnableNowPlaying = true }, "enableNowPlaying", true),
|
||||||
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
Entry("gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" }, "gaTrackingId", "UA-12345"),
|
||||||
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
|
Entry("defaultDownloadableShare", func() { conf.Server.DefaultDownloadableShare = true }, "defaultDownloadableShare", true),
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ var _ = Describe("Artwork Serving", Ordered, func() {
|
||||||
|
|
||||||
artRouter = buildArtworkRouter(artSvc)
|
artRouter = buildArtworkRouter(artSvc)
|
||||||
router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router
|
router = artRouter // so the shared doReq/doRawReq helpers hit the artwork-wired router
|
||||||
pubRouter = public.New(ds, artSvc, streamerSpy, core.NewShare(ds), noopArchiver{})
|
pubRouter = public.New(ds, artSvc, streamerSpy, stream.NewTranscodeDecider(ds, ffm), core.NewShare(ds), noopArchiver{})
|
||||||
})
|
})
|
||||||
|
|
||||||
It("emits a bare optimistic coverArt id before the queue is drained", func() {
|
It("emits a bare optimistic coverArt id before the queue is drained", func() {
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,15 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() {
|
||||||
resp = doReq("setRating", "id", songID)
|
resp = doReq("setRating", "id", songID)
|
||||||
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
It("rejects ratings outside the 0-5 range", func() {
|
||||||
|
for _, rating := range []string{"-1", "6"} {
|
||||||
|
resp := doReq("setRating", "id", songID, "rating", rating)
|
||||||
|
|
||||||
|
Expect(resp.Status).To(Equal(responses.StatusFailed))
|
||||||
|
Expect(resp.Error).ToNot(BeNil())
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
Describe("Scrobble", func() {
|
Describe("Scrobble", func() {
|
||||||
|
|
|
||||||
|
|
@ -17,16 +17,25 @@ import (
|
||||||
"github.com/navidrome/navidrome/utils/req"
|
"github.com/navidrome/navidrome/utils/req"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxRating is the highest value accepted by the Subsonic spec for setRating. 0 clears the rating.
|
||||||
|
const maxRating = 5
|
||||||
|
|
||||||
func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) {
|
func (api *Router) SetRating(r *http.Request) (*responses.Subsonic, error) {
|
||||||
p := req.Params(r)
|
p := req.Params(r)
|
||||||
id, err := p.String("id")
|
id, err := p.String("id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
rating, err := p.Int("rating")
|
rating64, err := p.Int64("rating")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Out-of-range values would otherwise land in average_rating, which every user sees.
|
||||||
|
// Check before narrowing to int, so a value like 4294967296 cannot wrap into range on 32-bit builds.
|
||||||
|
if rating64 < 0 || rating64 > maxRating {
|
||||||
|
return nil, newError(responses.ErrorGeneric, "Invalid rating: %d. Must be between 0 and %d", rating64, maxRating)
|
||||||
|
}
|
||||||
|
rating := int(rating64)
|
||||||
|
|
||||||
log.Debug(r, "Setting rating", "rating", rating, "id", id)
|
log.Debug(r, "Setting rating", "rating", rating, "id", id)
|
||||||
err = api.setRating(r.Context(), id, rating)
|
err = api.setRating(r.Context(), id, rating)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package subsonic
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -233,6 +234,53 @@ var _ = Describe("MediaAnnotationController", func() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("SetRating range validation", func() {
|
||||||
|
var plRepo *tests.MockPlaylistRepo
|
||||||
|
|
||||||
|
BeforeEach(func() {
|
||||||
|
plRepo = tests.CreateMockPlaylistRepo()
|
||||||
|
plRepo.SetData(model.Playlists{{ID: "pl-1", Name: "My Playlist", OwnerID: "u1"}})
|
||||||
|
ds.(*tests.MockDataStore).MockedPlaylist = plRepo
|
||||||
|
})
|
||||||
|
|
||||||
|
DescribeTable("rejects ratings outside the 0-5 range allowed by the Subsonic spec",
|
||||||
|
func(rating string) {
|
||||||
|
_, err := router.SetRating(newGetRequest("id=pl-1", "rating=4"))
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
|
||||||
|
r := newGetRequest("id=pl-1", "rating="+rating)
|
||||||
|
|
||||||
|
_, err = router.SetRating(r)
|
||||||
|
|
||||||
|
Expect(err).To(MatchError(errSubsonic))
|
||||||
|
var subErr subError
|
||||||
|
Expect(errors.As(err, &subErr)).To(BeTrue())
|
||||||
|
Expect(subErr.code).To(Equal(responses.ErrorGeneric))
|
||||||
|
Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", 4))
|
||||||
|
},
|
||||||
|
Entry("above maximum", "6"),
|
||||||
|
Entry("well above maximum", "1000"),
|
||||||
|
Entry("negative", "-3"),
|
||||||
|
// These wrap to 0 and 5 when narrowed to a 32-bit int.
|
||||||
|
Entry("2^32 (wraps to 0 on 32-bit)", "4294967296"),
|
||||||
|
Entry("2^32+5 (wraps to 5 on 32-bit)", "4294967301"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DescribeTable("accepts ratings inside the 0-5 range",
|
||||||
|
func(rating int) {
|
||||||
|
r := newGetRequest("id=pl-1", fmt.Sprintf("rating=%d", rating))
|
||||||
|
|
||||||
|
_, err := router.SetRating(r)
|
||||||
|
|
||||||
|
Expect(err).ToNot(HaveOccurred())
|
||||||
|
Expect(plRepo.Ratings).To(HaveKeyWithValue("pl-1", rating))
|
||||||
|
},
|
||||||
|
Entry("zero clears the rating", 0),
|
||||||
|
Entry("middle of the range", 3),
|
||||||
|
Entry("maximum", 5),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
Describe("Star with an unresolvable id", func() {
|
Describe("Star with an unresolvable id", func() {
|
||||||
It("skips the id without broadcasting an empty (wildcard) refresh", func() {
|
It("skips the id without broadcasting an empty (wildcard) refresh", func() {
|
||||||
r := newGetRequest("id=does-not-exist")
|
r := newGetRequest("id=does-not-exist")
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,17 @@ func (m *MockLibraryRepo) ScanEnd(_ context.Context, id int) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockLibraryRepo) SetScannedPID(_ context.Context, id int, pid model.PIDConfig) error {
|
||||||
|
if m.Err != nil {
|
||||||
|
return m.Err
|
||||||
|
}
|
||||||
|
if lib, ok := m.Data[id]; ok {
|
||||||
|
lib.ScannedPIDAlbum, lib.ScannedPIDTrack = pid.Album, pid.Track
|
||||||
|
m.Data[id] = lib
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) {
|
func (m *MockLibraryRepo) ScanInProgress(_ context.Context) (bool, error) {
|
||||||
if m.Err != nil {
|
if m.Err != nil {
|
||||||
return false, m.Err
|
return false, m.Err
|
||||||
|
|
|
||||||
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
|
|
@ -23,7 +23,7 @@
|
||||||
"inflection": "^3.0.2",
|
"inflection": "^3.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"navidrome-music-player": "4.25.4",
|
"navidrome-music-player": "4.25.5",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"ra-data-json-server": "^3.19.12",
|
"ra-data-json-server": "^3.19.12",
|
||||||
"ra-i18n-polyglot": "^3.19.12",
|
"ra-i18n-polyglot": "^3.19.12",
|
||||||
|
|
@ -8613,9 +8613,9 @@
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/navidrome-music-player": {
|
"node_modules/navidrome-music-player": {
|
||||||
"version": "4.25.4",
|
"version": "4.25.5",
|
||||||
"resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.4.tgz",
|
"resolved": "https://registry.npmjs.org/navidrome-music-player/-/navidrome-music-player-4.25.5.tgz",
|
||||||
"integrity": "sha512-5N7N94aJMIAfKZ0EKFfsHD95q2HBTAnAWyju0JflKnF+2u9Gr3qwnp3ZaaIC2K3z2GA69z0hqK0aOQ40FYsFrw==",
|
"integrity": "sha512-fXuiwEI9MgtY92R74GidRweJjIVQszHYOw1azO+giQDRGOOw941qBVuMOy3AgkGbtyWye+SJtwn4ipr0MwhqjQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
"inflection": "^3.0.2",
|
"inflection": "^3.0.2",
|
||||||
"jwt-decode": "^4.0.0",
|
"jwt-decode": "^4.0.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"navidrome-music-player": "4.25.4",
|
"navidrome-music-player": "4.25.5",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
"ra-data-json-server": "^3.19.12",
|
"ra-data-json-server": "^3.19.12",
|
||||||
"ra-i18n-polyglot": "^3.19.12",
|
"ra-i18n-polyglot": "^3.19.12",
|
||||||
|
|
|
||||||
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